├── app ├── logs │ └── .gitkeep ├── helpers │ ├── __init__.py │ ├── constants.py │ └── strings.py ├── main │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_item_image_thumbnail.py │ │ └── 0001_initial.py │ ├── templates │ │ └── main │ │ │ ├── categories.html │ │ │ ├── home.html │ │ │ ├── includes │ │ │ ├── breadcrumb.html │ │ │ ├── banner_2.html │ │ │ ├── footer_small.html │ │ │ ├── banner_1.html │ │ │ ├── text_section.html │ │ │ ├── category_cards.html │ │ │ ├── email_form.html │ │ │ ├── messages.html │ │ │ ├── navbar.html │ │ │ ├── item_section.html │ │ │ ├── footer.html │ │ │ └── icon_section.html │ │ │ ├── items.html │ │ │ ├── go_back_home.html │ │ │ ├── contact_us.html │ │ │ ├── login.html │ │ │ ├── register.html │ │ │ └── index.html │ ├── apps.py │ ├── storage_backends.py │ ├── errors.py │ ├── forms.py │ ├── mixins.py │ ├── item_content_template.html │ ├── serializers.py │ ├── models.py │ ├── admin.py │ └── api_views.py ├── tests │ ├── __init__.py │ ├── test_models │ │ ├── __init__.py │ │ ├── test_storage_backends.py │ │ ├── test_item.py │ │ └── test_category.py │ ├── test_views │ │ ├── __init__.py │ │ ├── test_home.py │ │ ├── test_invalid_url.py │ │ ├── test_logout.py │ │ ├── test_category.py │ │ ├── test_login.py │ │ ├── test_register.py │ │ └── test_contactus.py │ ├── utils.py │ ├── mocks.py │ └── conftest.py ├── portfolio │ ├── __init__.py │ ├── wsgi.py │ ├── settings │ │ ├── __init__.py │ │ └── base.py │ └── urls.py ├── static │ ├── Logo.png │ ├── favicon.ico │ ├── background1.jpg │ ├── background2.jpg │ ├── logo-twitter.png │ ├── logo-youtube.png │ ├── logo-facebook.png │ ├── logo-instagram.png │ ├── apple-touch-icon.png │ ├── js │ │ └── init.js │ └── css │ │ ├── googleapis.css │ │ └── style.css ├── manage.py ├── .env └── config.py ├── deployment ├── dev │ ├── ansible │ │ ├── roles │ │ │ └── common │ │ │ │ ├── files │ │ │ │ └── main.yaml │ │ │ │ ├── defaults │ │ │ │ └── main.yaml │ │ │ │ ├── handlers │ │ │ │ └── main.yaml │ │ │ │ ├── templates │ │ │ │ └── main.yaml │ │ │ │ ├── tasks │ │ │ │ ├── 5_install_docker_compose.yaml │ │ │ │ ├── main.yaml │ │ │ │ ├── 4_install_docker.yaml │ │ │ │ ├── 6_config_docker.yaml │ │ │ │ ├── 1_install_packages.yaml │ │ │ │ ├── 2_install_conda.yaml │ │ │ │ ├── 3_install_poetry.yaml │ │ │ │ └── 7_deploy.yaml │ │ │ │ ├── meta │ │ │ │ └── main.yaml │ │ │ │ └── vars │ │ │ │ └── main.yaml │ │ ├── inventories │ │ │ └── staging │ │ │ │ ├── group_vars │ │ │ │ └── all │ │ │ │ └── host_vars │ │ │ │ └── all │ │ ├── site.yaml │ │ ├── staging.yaml │ │ └── secrets.yaml │ ├── terraform │ │ ├── modules │ │ │ ├── ec2 │ │ │ │ ├── config.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── main.tf │ │ │ │ └── variables.tf │ │ │ └── sg │ │ │ │ ├── config.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── variables.tf │ │ │ │ └── main.tf │ │ ├── templates │ │ │ └── staging_hosts.tpl │ │ ├── terraform.tfvars.template │ │ ├── outputs.tf │ │ ├── config.tf │ │ ├── variables.tf │ │ ├── main.tf │ │ └── .terraform.lock.hcl │ └── README.md ├── prod │ ├── codedeploy-app │ │ ├── startup_server.sh │ │ ├── scripts │ │ │ ├── 3_after_install.sh │ │ │ ├── 2_before_install.sh │ │ │ ├── 1_stop_server.sh │ │ │ ├── 5_validate_service.sh │ │ │ └── 4_start_server.sh │ │ └── appspec.yml │ └── cloudformation │ │ ├── serverless │ │ └── src │ │ │ ├── sns_lambda_handler.py │ │ │ ├── sqs_lambda_handler.py │ │ │ └── codedeploy_lambda_handler.py │ │ ├── parameters.json │ │ ├── database │ │ └── template.yaml │ │ └── parent-stack.yaml ├── config │ └── startup_server.sh ├── docker-compose.yml └── webapp.Dockerfile ├── .github ├── cw-dashboard.png ├── ci_cd_pipeline.png ├── ci_cd_pipeline.pptx ├── load-testing-ui.png ├── app-architecture.png ├── app-architecture.pptx ├── app │ ├── app_homepage.png │ ├── app_item_page.png │ └── django_admin_and_login_page.png ├── s3-website-failover.png ├── app.svg └── coverage.svg ├── environment.yml ├── utils ├── helpers.mk └── locustfile.py ├── .dockerignore ├── .yamllint ├── LICENSE ├── .gitignore ├── .pre-commit-config.yaml ├── pyproject.toml ├── .circleci └── config.yml └── Makefile /app/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/portfolio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/main/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/test_models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tests/test_views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/files/main.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/defaults/main.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/handlers/main.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/templates/main.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /deployment/dev/ansible/inventories/staging/group_vars/all: -------------------------------------------------------------------------------- 1 | --- -------------------------------------------------------------------------------- /deployment/dev/ansible/inventories/staging/host_vars/all: -------------------------------------------------------------------------------- 1 | --- -------------------------------------------------------------------------------- /deployment/dev/ansible/site.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - include: staging.yaml 3 | -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/startup_server.sh: -------------------------------------------------------------------------------- 1 | ../../config/startup_server.sh -------------------------------------------------------------------------------- /app/static/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/Logo.png -------------------------------------------------------------------------------- /app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/favicon.ico -------------------------------------------------------------------------------- /.github/cw-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/cw-dashboard.png -------------------------------------------------------------------------------- /.github/ci_cd_pipeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/ci_cd_pipeline.png -------------------------------------------------------------------------------- /.github/ci_cd_pipeline.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/ci_cd_pipeline.pptx -------------------------------------------------------------------------------- /.github/load-testing-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/load-testing-ui.png -------------------------------------------------------------------------------- /app/static/background1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/background1.jpg -------------------------------------------------------------------------------- /app/static/background2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/background2.jpg -------------------------------------------------------------------------------- /app/static/logo-twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/logo-twitter.png -------------------------------------------------------------------------------- /app/static/logo-youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/logo-youtube.png -------------------------------------------------------------------------------- /.github/app-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/app-architecture.png -------------------------------------------------------------------------------- /.github/app-architecture.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/app-architecture.pptx -------------------------------------------------------------------------------- /.github/app/app_homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/app/app_homepage.png -------------------------------------------------------------------------------- /.github/app/app_item_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/app/app_item_page.png -------------------------------------------------------------------------------- /app/static/logo-facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/logo-facebook.png -------------------------------------------------------------------------------- /app/static/logo-instagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/logo-instagram.png -------------------------------------------------------------------------------- /.github/s3-website-failover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/s3-website-failover.png -------------------------------------------------------------------------------- /app/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/app/static/apple-touch-icon.png -------------------------------------------------------------------------------- /.github/app/django_admin_and_login_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbourniq/django-on-aws/HEAD/.github/app/django_admin_and_login_page.png -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: django-on-aws 2 | channels: 3 | - defaults 4 | - conda-forge 5 | dependencies: 6 | - python==3.8.0 # PSF 7 | -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/ec2/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 0.14" 3 | required_providers { 4 | aws = "~> 2.0" 5 | } 6 | } -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/sg/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 0.14" 3 | required_providers { 4 | aws = "~> 2.0" 5 | } 6 | } -------------------------------------------------------------------------------- /deployment/dev/ansible/staging.yaml: -------------------------------------------------------------------------------- 1 | - hosts: staging 2 | remote_user: ec2-user 3 | become: yes 4 | roles: 5 | - common 6 | vars_files: 7 | - secrets.yaml 8 | -------------------------------------------------------------------------------- /app/static/js/init.js: -------------------------------------------------------------------------------- 1 | (function($){ 2 | $(function(){ 3 | 4 | $('.sidenav').sidenav(); 5 | $('.parallax').parallax(); 6 | 7 | }); // end of document ready 8 | })(jQuery); // end of jQuery name space 9 | -------------------------------------------------------------------------------- /deployment/dev/terraform/templates/staging_hosts.tpl: -------------------------------------------------------------------------------- 1 | [staging] 2 | %{ for ip in public_ips ~} 3 | ${ip} 4 | %{ endfor ~} 5 | 6 | [staging:vars] 7 | ansible_ssh_private_key_file=~/.ssh/terraform_login_key.pem 8 | ansible_user=ec2-user -------------------------------------------------------------------------------- /deployment/dev/terraform/terraform.tfvars.template: -------------------------------------------------------------------------------- 1 | aws_pem_key_name = "mypersonalkeypair" 2 | environment = "dev" 3 | instance_count = 2 4 | provisioning_logs = "~/path/to/tf_provisioning.log" 5 | tag_name = "Dev deployment" 6 | vpn_ip = "123.123.123.123/32" 7 | -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/scripts/3_after_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | # Define tasks after the install hook (after CodeDeploy agent copied files to the host), 4 | # such as configuring your application or changing file permissions. 5 | 6 | sudo chmod +x /home/ec2-user/mounts/startup_server.sh -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/ec2/outputs.tf: -------------------------------------------------------------------------------- 1 | output "arn" { 2 | description = "Arn of the created ec2 instance" 3 | value = aws_instance.myec2.arn 4 | } 5 | 6 | output "public_ip" { 7 | description = "Public IP of the created ec2 instance" 8 | value = aws_instance.myec2.public_ip 9 | } -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/5_install_docker_compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Fetch docker-compose 4 | get_url: 5 | url: "https://github.com/docker/compose/releases/download/{{ docker_compose_version }}/docker-compose-Linux-x86_64" 6 | dest: /usr/local/bin/docker-compose 7 | mode: '0755' 8 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/meta/main.yaml: -------------------------------------------------------------------------------- 1 | galaxy_info: 2 | author: Guillaume Bournique 3 | description: Roles for provisioning EC2 instances with git repositories and required software and packages 4 | 5 | min_ansible_version: 2.0 6 | 7 | galaxy_tags: docker, terraform, aws 8 | 9 | dependencies: [] 10 | -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/scripts/2_before_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | # Preinstall tasks, such as decrypting files and creating a backup of the current version 4 | # or any cleanup tasks before copying new files to the host (Install Hook) 5 | 6 | echo "Clean up existing files" 7 | sudo rm -rf /home/ec2-user/mounts 8 | -------------------------------------------------------------------------------- /app/main/templates/main/categories.html: -------------------------------------------------------------------------------- 1 | {% extends 'main/index.html' %} 2 | 3 | {% block content %} 4 | {% include "main/includes/text_section.html" %} 5 | {% include "main/includes/category_cards.html" %} 6 |


7 | {% endblock %} 8 | 9 | {% block footer %} 10 | {% include "main/includes/footer_small.html" %} 11 | {% endblock %} -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - import_tasks: 1_install_packages.yaml 4 | - import_tasks: 2_install_conda.yaml 5 | - import_tasks: 3_install_poetry.yaml 6 | - import_tasks: 4_install_docker.yaml 7 | - import_tasks: 5_install_docker_compose.yaml 8 | - import_tasks: 6_config_docker.yaml 9 | - import_tasks: 7_deploy.yaml 10 | -------------------------------------------------------------------------------- /deployment/dev/terraform/outputs.tf: -------------------------------------------------------------------------------- 1 | output "public_ips" { 2 | description = "Public IPs of the provisioned instances" 3 | value = join("\n", formatlist("%s:8080", module.myec2instances.*.public_ip)) 4 | } 5 | 6 | output "workspace_name" { 7 | description = "Workspace used to create resources" 8 | value = terraform.workspace 9 | } 10 | -------------------------------------------------------------------------------- /app/main/templates/main/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'main/index.html' %} 2 | 3 | {% block content %} 4 | {% include "main/includes/banner_1.html" %} 5 | {% include "main/includes/icon_section.html" %} 6 | {% include "main/includes/banner_2.html" %} 7 | {% endblock %} 8 | 9 | {% block footer %} 10 | {% include "main/includes/footer.html" %} 11 | {% endblock %} -------------------------------------------------------------------------------- /app/main/templates/main/includes/breadcrumb.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 8 | -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/ec2/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "myec2" { 2 | ami = var.ami 3 | key_name = var.key_name 4 | instance_type = var.instance_type 5 | iam_instance_profile = var.iam_instance_profile 6 | vpc_security_group_ids = var.vpc_security_group_ids 7 | tags = var.tags 8 | } -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/sg/outputs.tf: -------------------------------------------------------------------------------- 1 | output "dynamic_sg_id" { 2 | description = "Id of the created security group with dynamic ports" 3 | value = aws_security_group.dynamic_sg.id 4 | } 5 | 6 | output "ssh_sg_id" { 7 | description = "Id of the created security group for ssh access" 8 | value = var.vpn_ip != "" ? aws_security_group.ssh_sg.id : null 9 | } -------------------------------------------------------------------------------- /app/main/templates/main/items.html: -------------------------------------------------------------------------------- 1 | {% extends 'main/index.html' %} 2 | 3 | {% block header %} 4 | {% include "main/includes/breadcrumb.html" %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 | {% include "main/includes/item_section.html" %} 9 |

10 | {% endblock %} 11 | 12 | {% block footer %} 13 | {% include "main/includes/footer_small.html" %} 14 | {% endblock %} -------------------------------------------------------------------------------- /deployment/dev/terraform/config.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 0.14" 3 | required_providers { 4 | aws = "~> 2.0" 5 | null = "~> 3.1" 6 | local = "~> 2.1" 7 | } 8 | backend "s3" { 9 | region = "eu-west-2" 10 | bucket = "terraform-remote-backend-gb" 11 | key = "terraform.tfstate" 12 | # dynamodb_table = "s3-state-lock" 13 | } 14 | } -------------------------------------------------------------------------------- /app/main/templates/main/includes/banner_2.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 |
4 |
5 |
6 |
Un voyage culinaire en Vendée 🏝
7 |
8 |
9 |
10 |
Unsplashed background img 2
11 |
-------------------------------------------------------------------------------- /utils/helpers.mk: -------------------------------------------------------------------------------- 1 | # Makefile helpers 2 | 3 | # Cosmetics 4 | RED := "\e[1;31m" 5 | YELLOW := "\e[1;33m" 6 | GREEN := "\033[32m" 7 | NC := "\e[0m" 8 | INFO := bash -c 'printf ${YELLOW}; echo "[INFO] $$1"; printf ${NC}' MESSAGE 9 | MESSAGE := bash -c 'printf ${NC}; echo "$$1"; printf ${NC}' MESSAGE 10 | SUCCESS := bash -c 'printf ${GREEN}; echo "[SUCCESS] $$1"; printf ${NC}' MESSAGE 11 | WARNING := bash -c 'printf ${RED}; echo "[WARNING] $$1"; printf ${NC}' MESSAGEs 12 | -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/sg/variables.tf: -------------------------------------------------------------------------------- 1 | variable "sg_ports" { 2 | type = list(number) 3 | description = "list of ingress ports" 4 | default = [80, 443] 5 | } 6 | 7 | variable "vpn_ip" { 8 | type = string 9 | description = "your IP address for ssh access" 10 | default = "" 11 | } 12 | 13 | variable "tags" { 14 | description = "A mapping of tags to assign to the resource." 15 | type = map(string) 16 | default = {} 17 | } -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/scripts/1_stop_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | # Gracefully stop the application or remove currently installed packages in preparation for a deployment. 4 | # Note the ApplicationStop lifecycle event runs the script from last successful revision, 5 | # while all other lifecycle events run scripts from the current revision. 6 | 7 | echo "Clean up any running containers" 8 | docker stop $(docker ps -a -q) || true 9 | docker rm $(docker ps -a -q) || true -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/4_install_docker.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Install Docker-CE via amazon-linux-extras packages 4 | shell: "amazon-linux-extras install docker={{ docker_version }} -y" 5 | 6 | - name: Enable Docker CE service at startup 7 | service: 8 | name: docker 9 | state: started 10 | enabled: yes 11 | 12 | - name: Add the user to the docker group 13 | user: 14 | name: "{{ user }}" 15 | groups: docker 16 | append: yes 17 | -------------------------------------------------------------------------------- /deployment/dev/ansible/secrets.yaml: -------------------------------------------------------------------------------- 1 | dockerhub_password: !vault | 2 | $ANSIBLE_VAULT;1.1;AES256 3 | 38653438393439653936393436303932613063613537663964316333623533386132393739303462 4 | 3437663562636135333133333237353230303663313234340a616632313336623161393464336362 5 | 32323562386665373034656466366338333263633365323134653862313361333137346234316334 6 | 3130656435323262610a646464316663643635373838653033343538633764383437653336323936 7 | 6431 8 | -------------------------------------------------------------------------------- /app/main/templates/main/includes/footer_small.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 10 | -------------------------------------------------------------------------------- /app/main/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Application configuration objects store metadata for an application. 3 | Some attributes can be configured in AppConfig subclasses. 4 | Others are set by Django and read-only. 5 | """ 6 | 7 | from django.apps import AppConfig 8 | 9 | 10 | class MainConfig(AppConfig): 11 | """ 12 | Defines the name of the application to be installed in the 13 | Django settings under INSTALLED_APPS 14 | """ 15 | 16 | name = "main" 17 | verbose_name = "Gestion des Recettes" 18 | -------------------------------------------------------------------------------- /app/main/migrations/0002_item_image_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-15 19:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("main", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="item", 15 | name="image_thumbnail", 16 | field=models.ImageField(default="", upload_to="images/"), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /app/portfolio/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for portfolio project which is used when starting a production 3 | server with gunicorn. 4 | 5 | It exposes the WSGI callable as a module-level variable named ``application``. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 9 | """ 10 | 11 | import os 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /app/main/templates/main/go_back_home.html: -------------------------------------------------------------------------------- 1 | {% extends "main/index.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |




8 |
{{message}}
9 |
10 | Back Home 11 |
12 |
13 |
14 |







15 | 16 | {% endblock %} 17 | 18 | {% block footer %} 19 | {% include "main/includes/footer_small.html" %} 20 | {% endblock %} -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Artifacts to exclude when sending build context to the docker daemon 2 | 3 | # Dotfiles, cache, build artifacts, test, etc. 4 | .coverage 5 | .coveragerc 6 | .dockerignore 7 | .circleci 8 | .github 9 | .gitignore 10 | .pre-commit-config.yaml 11 | .pylintrc 12 | app.egg-info 13 | app/tests/ 14 | bin/ 15 | build/ 16 | docker-compose.yml 17 | Dockerfile 18 | htmlcov 19 | Makefile 20 | 21 | # Cache 22 | .pytest_cache/ 23 | __pycache__/ 24 | 25 | # Everything under deployent other than script files and python files 26 | deployment/**/* 27 | !deployment/config/* 28 | 29 | # IDE stuff 30 | .idea/ 31 | .vscode/ -------------------------------------------------------------------------------- /utils/locustfile.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file defines load testing behaviours for the web application 3 | """ 4 | 5 | 6 | from locust import HttpUser, between, task 7 | 8 | 9 | class QuickstartUser(HttpUser): 10 | wait_time = between(1, 2.5) 11 | 12 | @task(10) 13 | def index(self): 14 | self.client.get("/") 15 | 16 | # @task(3) 17 | # def view_items(self): 18 | # for item_id in range(10): 19 | # self.client.get(f"/item?id={item_id}", name="/item") 20 | # time.sleep(1) 21 | 22 | def on_start(self): 23 | pass 24 | # self.client.post("/login", json={"username":"foo", "password":"bar"}) 25 | -------------------------------------------------------------------------------- /app/static/css/googleapis.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(https://fonts.gstatic.com/s/materialicons/v48/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | text-rendering: optimizeLegibility; 22 | -webkit-font-smoothing: antialiased; 23 | } -------------------------------------------------------------------------------- /.github/app.svg: -------------------------------------------------------------------------------- 1 | pylintpylint10.010.0 -------------------------------------------------------------------------------- /app/tests/test_views/test_home.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the home page""" 2 | 3 | from http import HTTPStatus 4 | 5 | import pytest 6 | from django.urls import reverse 7 | 8 | from helpers.constants import TemplateNames 9 | 10 | 11 | @pytest.mark.django_db(transaction=True) 12 | class TestViewHome: 13 | """Tests for the home page""" 14 | 15 | @pytest.mark.integration 16 | # pylint: disable=no-self-use 17 | def test_view_homepage(self, client): 18 | """Test the view home page template is rendered""" 19 | response = client.get(reverse("home")) 20 | assert TemplateNames.HOME.value in [t.name for t in response.templates] 21 | assert response.status_code == HTTPStatus.OK.value 22 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | yaml-files: 4 | - '*.yaml' 5 | - '*.yml' 6 | - '.yamllint' 7 | 8 | rules: 9 | braces: enable 10 | brackets: enable 11 | colons: enable 12 | commas: enable 13 | comments: 14 | level: warning 15 | comments-indentation: 16 | level: warning 17 | document-end: disable 18 | document-start: 19 | level: warning 20 | empty-lines: enable 21 | empty-values: disable 22 | hyphens: enable 23 | indentation: enable 24 | key-duplicates: enable 25 | key-ordering: disable 26 | line-length: 27 | max: 130 28 | level: warning 29 | new-line-at-end-of-file: enable 30 | new-lines: enable 31 | octal-values: disable 32 | quoted-strings: disable 33 | trailing-spaces: enable 34 | truthy: 35 | level: warning 36 | -------------------------------------------------------------------------------- /app/main/templates/main/includes/banner_1.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 |
4 |
5 |

 

6 |
7 |
  Exquisite food by Chef Tari  
8 |
9 | 12 |

13 | 14 |
15 |
16 |
Unsplashed background img 1
17 |
18 | -------------------------------------------------------------------------------- /app/main/templates/main/includes/text_section.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 |
4 | 5 |
6 |
7 |
8 |

Recettes 👩🏻‍🍳

9 |
10 | 13 |
14 |
15 | 16 |
17 |
-------------------------------------------------------------------------------- /app/main/templates/main/includes/category_cards.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 |
4 | {% for cat in all_categories_list %} 5 | 19 | {% endfor %} 20 |
21 |
-------------------------------------------------------------------------------- /app/tests/test_models/test_storage_backends.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the S3 storage backend configuration""" 2 | 3 | from app.main.storage_backends import PublicMediaStorage, StaticStorage 4 | 5 | 6 | def test_static_storage_instance(): 7 | """Tests AWS storage backend variables are set""" 8 | static_storage = StaticStorage() 9 | assert static_storage.location == "staticfiles/" 10 | assert static_storage.default_acl == "public-read" 11 | 12 | 13 | def test_public_media_storage_instance(): 14 | """Tests AWS storage backend variables are set""" 15 | public_media_storage = PublicMediaStorage() 16 | assert public_media_storage.location == "mediafiles/" 17 | assert public_media_storage.default_acl == "public-read" 18 | assert not public_media_storage.file_overwrite 19 | -------------------------------------------------------------------------------- /deployment/config/startup_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /home/portfoliouser/app 4 | 5 | echo "Collecting static files" 6 | python manage.py collectstatic --no-input -v 0 7 | 8 | echo "Running migrations" 9 | python manage.py makemigrations main 10 | python manage.py migrate 11 | 12 | # echo "Django superuser must be create manually with python manage.py createsuperuser" 13 | echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'gbournique@gmail.com', 'admin')" | python manage.py shell 2>/dev/null || true 14 | 15 | echo "Create log file if doesn't exist" 16 | mkdir /home/portfoliouser/app/logs/ 17 | touch /home/portfoliouser/app/logs/info.log 18 | 19 | echo "Starting webserver" 20 | gunicorn portfolio.wsgi:application --bind 0.0.0.0:8080 21 | -------------------------------------------------------------------------------- /app/helpers/constants.py: -------------------------------------------------------------------------------- 1 | """This module defines constants to be used across the app code base""" 2 | 3 | from collections import namedtuple 4 | from enum import Enum 5 | 6 | TEMPLATE_DIR = "main" 7 | 8 | DIMS = namedtuple("DIMS", "width height") 9 | CROP_SIZE = DIMS(300, 300) 10 | THUMBNAIL_SIZE = DIMS(500, 500) 11 | IMG_EXT = ".jpg" 12 | THUMBNAIL_SUFFIX = "_thumbnail" 13 | 14 | 15 | class TemplateNames(Enum): 16 | """Enum to gather template name""" 17 | 18 | HOME = f"{TEMPLATE_DIR}/home.html" 19 | REGISTER = f"{TEMPLATE_DIR}/register.html" 20 | LOGIN = f"{TEMPLATE_DIR}/login.html" 21 | CATEGORIES = f"{TEMPLATE_DIR}/categories.html" 22 | ITEMS = f"{TEMPLATE_DIR}/items.html" 23 | CONTACT_US = f"{TEMPLATE_DIR}/contact_us.html" 24 | GO_BACK_HOME = f"{TEMPLATE_DIR}/go_back_home.html" 25 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/vars/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | docker_version: "18.06.1" 3 | docker_compose_version: "1.24.1" 4 | dockerhub_user: "{{ lookup('env','DOCKER_USER') }}" 5 | docker_image_name: "{{ dockerhub_user }}/django-on-aws" 6 | full_path_repositories_dir: "{{ user_path }}/repos" 7 | full_path_django_repo_dir: "{{ full_path_repositories_dir }}/{{ lookup('env','ANSIBLE_GIT_REPO_NAME') }}" 8 | git_repository: "git@github.com:gbourniq/{{ lookup('env','ANSIBLE_GIT_REPO_NAME') }}.git" 9 | git_branch_name: "{{ lookup('env','ANSIBLE_GIT_BRANCH_NAME') }}" 10 | linux_shell_profile_name: "{{ user_path }}/.bashrc" 11 | poetry_version: "1.1.5" 12 | python_version: "{{ lookup('env','ANSIBLE_PYTHON_VERSION') or '3.8' }}" 13 | user: "{{ lookup('env','DEPLOYMENT_USER') or 'ec2-user' }}" 14 | user_path: "/home/{{ user }}" 15 | -------------------------------------------------------------------------------- /app/main/storage_backends.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines configurations for using S3 as a Django storage 3 | backend for static and media files 4 | """ 5 | 6 | from storages.backends.s3boto3 import S3Boto3Storage 7 | 8 | from app.config import MEDIA_FILES_PATH, STATIC_FILES_PATH 9 | 10 | 11 | # pylint: disable=abstract-method 12 | class StaticStorage(S3Boto3Storage): 13 | """Class used in settings.py to specify the S3 folder storing static files""" 14 | 15 | location = STATIC_FILES_PATH 16 | default_acl = "public-read" 17 | 18 | 19 | # pylint: disable=abstract-method 20 | class PublicMediaStorage(S3Boto3Storage): 21 | """Class used in settings.py to specify the S3 folder storing media files""" 22 | 23 | location = MEDIA_FILES_PATH 24 | default_acl = "public-read" 25 | file_overwrite = False 26 | -------------------------------------------------------------------------------- /app/main/templates/main/includes/email_form.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 |
4 |
5 |
6 | 7 |
8 |

Contact chat

9 |
10 |
11 | {% csrf_token %} 12 | {{ form.as_p }} 13 |
14 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |
-------------------------------------------------------------------------------- /app/main/errors.py: -------------------------------------------------------------------------------- 1 | """This module defines error handlers""" 2 | 3 | import logging 4 | from http import HTTPStatus 5 | 6 | from django.http import Http404 7 | from django.shortcuts import redirect, render 8 | 9 | from helpers import strings 10 | from helpers.constants import TemplateNames 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def url_error(request) -> redirect: 16 | """Handles unmatched URLs""" 17 | raise Http404(strings.MSG_404) 18 | 19 | 20 | def handler404(request, exception) -> render: 21 | """Function to handle any 404 error with a custom page""" 22 | logger.error(f"{str(exception)}") 23 | return render( 24 | request, 25 | TemplateNames.GO_BACK_HOME.value, 26 | context={ 27 | "message": f"{str(exception)}", 28 | "code_handled": HTTPStatus.NOT_FOUND.value, 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/appspec.yml: -------------------------------------------------------------------------------- 1 | # CodeDeploy troubleshooting: https://docs.aws.amazon.com/codedeploy/latest/userguide/troubleshooting-general.html 2 | version: 0.0 3 | os: linux 4 | files: 5 | - source: /startup_server.sh 6 | destination: /home/ec2-user/mounts/startup_server.sh 7 | hooks: 8 | ApplicationStop: 9 | - location: scripts/1_stop_server.sh 10 | timeout: 300 11 | runas: root 12 | 13 | BeforeInstall: 14 | - location: scripts/2_before_install.sh 15 | timeout: 300 16 | runas: root 17 | 18 | AfterInstall: 19 | - location: scripts/3_after_install.sh 20 | timeout: 300 21 | runas: root 22 | 23 | ApplicationStart: 24 | - location: scripts/4_start_server.sh 25 | timeout: 300 26 | runas: root 27 | 28 | ValidateService: 29 | - location: scripts/5_validate_service.sh 30 | timeout: 300 31 | -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/sg/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "dynamic_sg" { 2 | name = "dynamic-sg" 3 | description = "Inbound http/s and all outbound ports allowed" 4 | dynamic "ingress" { 5 | for_each = var.sg_ports 6 | iterator = port 7 | content { 8 | from_port = port.value 9 | to_port = port.value 10 | protocol = "tcp" 11 | cidr_blocks = ["0.0.0.0/0"] 12 | } 13 | } 14 | egress { 15 | from_port = 0 16 | to_port = 65535 17 | protocol = "tcp" 18 | cidr_blocks = ["0.0.0.0/0"] 19 | } 20 | tags = var.tags 21 | } 22 | 23 | resource "aws_security_group" "ssh_sg" { 24 | name = "ssh-sg" 25 | description = "Inbound SSH allowed" 26 | ingress { 27 | from_port = 22 28 | to_port = 22 29 | protocol = "tcp" 30 | cidr_blocks = [var.vpn_ip] 31 | } 32 | tags = var.tags 33 | } -------------------------------------------------------------------------------- /app/helpers/strings.py: -------------------------------------------------------------------------------- 1 | """API messages""" 2 | 3 | SIGNUP_MSG = "New account created: {account_name}. Please check your emails." 4 | LOGOUT = "Logged out successfully!" 5 | LOGIN = "You are now logged in as {username}" 6 | REDIRECT_AFTER_LOGIN = "Redirecting user back to {secure_page}" 7 | INVALID_LOGIN = "Invalid username or password." 8 | 9 | NO_ITEM_IN_CATEGORY = "Oops.. Category {category} does not contain any item!" 10 | MSG_404 = "Oops.. There's nothing here." 11 | MSG_500 = "Internal Server Error (500)" 12 | 13 | INVALID_FORM = "Email form is invalid." 14 | CONTACTUS_FORM = "Success! Thank you for your message." 15 | 16 | SNS_SERVICE_RESPONSE = "SNS service response: {response}" 17 | SNS_TOPIC_NOT_CONFIGURED_USER_FRIENDLY = "Oops, your email could not be sent." 18 | SNS_TOPIC_NOT_CONFIGURED = ( 19 | "ContactForm email not forward to Slack because the SNS_TOPIC_ARN " 20 | "environment variable is not configured." 21 | ) 22 | -------------------------------------------------------------------------------- /app/main/templates/main/includes/messages.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% if messages %} 3 | {% for message in messages %} 4 | {% if message.tags == 'success' %} 5 | 12 | {% elif message.tags == 'info' %} 13 | 20 | {% elif message.tags == 'warning' %} 21 | 28 | {% elif message.tags == 'error' %} 29 | 36 | {% endif %} 37 | {% endfor %} 38 | {% endif %} -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/6_config_docker.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # This step stops docker deployed application if it is running. 4 | # If there is no Docker service installed, or there are no containers running, 5 | # it will raise an error which will be ignored. 6 | - name: Stop and remove running containers 7 | shell: docker stop $(docker ps -aq) && docker rm $(docker ps -aq) 8 | ignore_errors: yes 9 | 10 | # If there is no Docker service installed, it will raise an error which will be ignored. 11 | - name: Prune Docker containers 12 | docker_prune: 13 | containers: yes 14 | images: yes 15 | images_filters: 16 | dangling: true 17 | ignore_errors: yes 18 | vars: 19 | ansible_python_interpreter: /usr/bin/python3 20 | 21 | - name: Log into Dockerhub 22 | docker_login: 23 | username: "{{ dockerhub_user }}" 24 | password: "{{ dockerhub_password }}" 25 | reauthorize: yes 26 | vars: 27 | ansible_python_interpreter: /usr/bin/python3 28 | -------------------------------------------------------------------------------- /.github/coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 96% 19 | 96% 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """ 10 | File to manage django administrative tasks. 11 | manage.py also sets the DJANGO_SETTINGS_MODULE environment variable 12 | so that it points to your project’s settings.py file. 13 | """ 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings") 15 | try: 16 | # pylint: disable=import-outside-toplevel 17 | from django.core.management import execute_from_command_line 18 | except ImportError as exc: 19 | raise ImportError( 20 | "Couldn't import Django. Are you sure it's installed and " 21 | "available on your PYTHONPATH environment variable? Did you " 22 | "forget to activate a virtual environment?" 23 | ) from exc 24 | execute_from_command_line(sys.argv) 25 | 26 | 27 | if __name__ == "__main__": 28 | main() 29 | -------------------------------------------------------------------------------- /app/tests/test_views/test_invalid_url.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for when invalid url paths are hit""" 2 | 3 | from http import HTTPStatus 4 | 5 | import pytest 6 | from django.urls import reverse 7 | 8 | from helpers import strings 9 | from helpers.constants import TemplateNames 10 | 11 | 12 | class TestInvalidUrl: 13 | """Tests for unmatched url paths""" 14 | 15 | @pytest.mark.integration 16 | # pylint: disable=no-self-use 17 | def test_invalid_url(self, client): 18 | """Tests that invalid url paths are handled""" 19 | # When: No url match 20 | response = client.get(reverse("error404")) 21 | # Then: Handled and returned 200 with a friendly error message 22 | assert response.status_code == HTTPStatus.OK.value 23 | assert response.context["code_handled"] == HTTPStatus.NOT_FOUND.value 24 | assert response.context["message"] == strings.MSG_404 25 | assert TemplateNames.GO_BACK_HOME.value in [t.name for t in response.templates] 26 | -------------------------------------------------------------------------------- /app/tests/utils.py: -------------------------------------------------------------------------------- 1 | """This module defines helper functions for unit tests""" 2 | from pathlib import Path 3 | from typing import Tuple 4 | 5 | from django.db.models.fields.files import ImageFieldFile 6 | from PIL import Image 7 | 8 | 9 | def create_dummy_png_image( 10 | dir_path: Path, image_name: str, image_size: Tuple[int, int] = (300, 300) 11 | ) -> None: 12 | """Creates a test PNG images""" 13 | Image.new("RGB", image_size, (255, 255, 255)).save( 14 | f"{dir_path}/{image_name}", "png" 15 | ) 16 | 17 | 18 | def create_dummy_file(dir_path: Path, filename: str) -> None: 19 | """Creates a test non-image files""" 20 | with (dir_path / filename).open("w", encoding="utf-8") as f: 21 | f.write("mock content") 22 | 23 | 24 | def check_image_attributes(image: ImageFieldFile, size: Tuple[int, int], ext: str): 25 | """Asserts the given image has the expected size and extension.""" 26 | assert Image.open(image).size == size 27 | assert Path(image.name).suffix == ext 28 | -------------------------------------------------------------------------------- /app/tests/test_views/test_logout.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the logout page""" 2 | 3 | from http import HTTPStatus 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | from django.urls import reverse 8 | 9 | 10 | @pytest.mark.django_db(transaction=True) 11 | class TestViewLogout: 12 | """Tests for the logout page""" 13 | 14 | @pytest.mark.integration 15 | # pylint: disable=no-self-use 16 | def test_click_logout_button(self, monkeypatch, client): 17 | """Test for when user click the logout button""" 18 | # Given: mock logout function 19 | monkeypatch.setattr("main.views.logout", mock_logout := Mock()) 20 | # When: GET request to the logout page 21 | response = client.get(reverse("logout")) 22 | # Then: mock logout is called 23 | mock_logout.assert_called_with(response.wsgi_request) 24 | # Then: No templates returned and user redirected 25 | assert len(response.templates) == 0 26 | assert response.status_code == HTTPStatus.FOUND.value 27 | -------------------------------------------------------------------------------- /app/main/templates/main/contact_us.html: -------------------------------------------------------------------------------- 1 | {% extends "main/index.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 | 8 |
9 |

Contact question_answer

10 |
11 |
12 | {% csrf_token %} 13 | {% load materializecss %} 14 | {{ form|materializecss }} 15 |

16 |
17 | 20 |
21 |
22 | 23 |
24 |
25 |
26 | 27 |


28 | 29 | {% endblock %} 30 | 31 | {% block footer %} 32 | {% include "main/includes/footer_small.html" %} 33 | {% endblock %} -------------------------------------------------------------------------------- /deployment/prod/cloudformation/serverless/src/sns_lambda_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os import getenv 3 | 4 | import urllib3 5 | 6 | http = urllib3.PoolManager() 7 | 8 | 9 | def handler(event, context): 10 | """Lambda function to forward SNS notifications to Slack""" 11 | try: 12 | # Initialize variables 13 | url = getenv("SLACK_WEBHOOK_URL") 14 | text = event["Records"][0]["Sns"]["Message"] 15 | 16 | # Check slack configuration 17 | if not url: 18 | print(f"Invalid slack configuration. SNS message: {text}") 19 | return 20 | # Post SNS message to Slack 21 | msg = { 22 | "channel": "#general", 23 | "username": "SNS", 24 | "text": text, 25 | "icon_emoji": ":cloud:", 26 | } 27 | encoded_msg = json.dumps(msg).encode("utf-8") 28 | resp = http.request("POST", url, body=encoded_msg) 29 | print({"message": text, "status_code": resp.status, "response": resp.data}) 30 | 31 | except Exception as excpt: 32 | print(f"Execution failed... {excpt}") 33 | -------------------------------------------------------------------------------- /app/main/templates/main/login.html: -------------------------------------------------------------------------------- 1 | {% extends "main/index.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 | 8 |
9 |

Log in

10 |
11 |
12 | {% csrf_token %} 13 | {% load materializecss %} 14 | {{ form|materializecss }} 15 |


16 | 17 |
18 | 21 |
22 | 23 |
24 |

25 | Don't have an account? register here! 26 | 27 |
28 |
29 |
30 | 31 |


32 | 33 | {% endblock %} 34 | 35 | {% block footer %} 36 | {% include "main/includes/footer_small.html" %} 37 | {% endblock %} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Guillaume Bournique 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /deployment/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | 5 | postgres: 6 | image: bitnami/postgresql:11.11.0 7 | container_name: postgres 8 | restart: unless-stopped 9 | environment: 10 | POSTGRESQL_DATABASE: portfoliodb 11 | POSTGRESQL_USERNAME: postgres 12 | POSTGRESQL_PASSWORD: postgres 13 | POSTGRESQL_POSTGRES_PASSWORD: postgres 14 | volumes: 15 | - pgdata:/bitnami/postgresql 16 | ports: 17 | - "5432:5432" 18 | healthcheck: 19 | test: ["CMD-SHELL", "pg_isready -U postgres"] 20 | interval: 30s 21 | timeout: 5s 22 | retries: 5 23 | networks: 24 | - backend 25 | 26 | redis: 27 | image: redis:alpine 28 | container_name: redis 29 | restart: "no" 30 | volumes: 31 | - redisdata:/data 32 | healthcheck: 33 | test: ["CMD", "redis-cli", "ping"] 34 | interval: 5s 35 | timeout: 5s 36 | retries: 10 37 | ports: 38 | - "6379:6379" 39 | networks: 40 | - backend 41 | 42 | volumes: 43 | redisdata: 44 | pgdata: 45 | pglogs: 46 | 47 | networks: 48 | backend: 49 | name: global-network 50 | driver: bridge 51 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/1_install_packages.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Upgrade all packages 4 | become_user: root 5 | yum: 6 | name: '*' 7 | state: latest 8 | lock_timeout: 180 9 | 10 | - name: Install a list of packages with a list variable 11 | yum: 12 | name: "{{ packages }}" 13 | update_cache: true 14 | state: present 15 | vars: 16 | packages: 17 | - git 18 | - python3 19 | - python3-pip 20 | - amazon-linux-extras 21 | 22 | - name: Add extras repository 23 | shell: yum-config-manager --enable extras 24 | 25 | - name: Add python alias 26 | lineinfile: 27 | dest="{{ user_path }}/.bashrc" 28 | line="alias python='python3'" 29 | state=present 30 | insertafter=EOF 31 | create=True 32 | 33 | - name: Add pip alias 34 | lineinfile: 35 | dest="{{ user_path }}/.bashrc" 36 | line="alias pip='pip3'" 37 | state=present 38 | insertafter=EOF 39 | create=True 40 | 41 | - name: Install pip packages 42 | pip: 43 | name: "{{ packages }}" 44 | executable: pip3 45 | extra_args: --user 46 | vars: 47 | packages: 48 | - docker 49 | - docker-compose 50 | - boto 51 | - boto3 52 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/2_install_conda.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Check if previous conda installation exists 4 | stat: 5 | path: "{{ user_path }}/miniconda3/" 6 | register: prev_inst_dir 7 | 8 | - name: Fetch miniconda installer 9 | become_user: ec2-user 10 | get_url: 11 | url: https://repo.anaconda.com/miniconda/Miniconda2-latest-Linux-x86_64.sh 12 | dest: /tmp/miniconda.sh 13 | when: prev_inst_dir.stat.isdir is not defined 14 | 15 | - name: Install conda 16 | become_user: ec2-user 17 | command: bash /tmp/miniconda.sh -b -u -p {{ user_path }}/miniconda3/ 18 | when: prev_inst_dir.stat.isdir is not defined 19 | 20 | - name: Add miniconda binaries directory to your PATH in {{ linux_shell_profile_name }} for {{ ansible_distribution }} distribution 21 | become_user: ec2-user 22 | shell: echo '. ~/miniconda3/etc/profile.d/conda.sh' | cat >> {{ linux_shell_profile_name }} 23 | when: prev_inst_dir.stat.isdir is not defined 24 | 25 | - name: Install python v{{ python_version }} to conda base environment 26 | become_user: ec2-user 27 | command: conda install -c miniconda python={{ python_version }} -y 28 | when: prev_inst_dir.stat.isdir is not defined 29 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/3_install_poetry.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Check if previous poetry installation exists 4 | stat: 5 | path: "{{ user_path }}/.poetry/" 6 | register: prev_inst_dir 7 | 8 | - name: Fetch poetry installer 9 | become_user: ec2-user 10 | get_url: 11 | url: https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py 12 | dest: "{{ user_path }}" 13 | when: prev_inst_dir.stat.isdir is not defined 14 | 15 | - name: Install Poetry v{{ poetry_version }} 16 | become_user: ec2-user 17 | command: python {{ user_path }}/get-poetry.py --version {{ poetry_version }} -y 18 | when: prev_inst_dir.stat.isdir is not defined 19 | 20 | - name: Add Poetry binaries directory to your PATH in {{ linux_shell_profile_name }} for {{ ansible_distribution }} distribution 21 | become_user: ec2-user 22 | shell: echo 'export PATH="$HOME/.poetry/bin:$PATH"' | cat >> {{ linux_shell_profile_name }} 23 | when: prev_inst_dir.stat.isdir is not defined 24 | 25 | - name: Set Poetry config virtualenvs to False 26 | become_user: ec2-user 27 | command: "{{ user_path }}/.poetry/bin/poetry config virtualenvs.create false" 28 | when: prev_inst_dir.stat.isdir is not defined 29 | -------------------------------------------------------------------------------- /app/static/css/style.css: -------------------------------------------------------------------------------- 1 | /* Custom Stylesheet */ 2 | /** 3 | * Use this file to override Materialize files so you can update 4 | * the core Materialize files in the future 5 | * 6 | * Made By MaterializeCSS.com 7 | */ 8 | 9 | body { 10 | display: flex; 11 | min-height: 100vh; 12 | flex-direction: column; 13 | } 14 | 15 | main { 16 | flex: 1 0 auto; 17 | } 18 | 19 | nav ul a, 20 | nav .brand-logo { 21 | color: #444; 22 | } 23 | 24 | p { 25 | line-height: 2rem; 26 | } 27 | 28 | .sidenav-trigger { 29 | color: #26a69a; 30 | } 31 | 32 | .parallax-container { 33 | min-height: 380px; 34 | line-height: 0; 35 | height: auto; 36 | color: rgba(255,255,255,.9); 37 | } 38 | .parallax-container .section { 39 | width: 100%; 40 | } 41 | 42 | @media only screen and (max-width : 992px) { 43 | .parallax-container .section { 44 | position: absolute; 45 | top: 40%; 46 | } 47 | #index-banner .section { 48 | top: 10%; 49 | } 50 | } 51 | 52 | @media only screen and (max-width : 600px) { 53 | #index-banner .section { 54 | top: 0; 55 | } 56 | } 57 | 58 | .icon-block { 59 | padding: 0 15px; 60 | } 61 | .icon-block .material-icons { 62 | font-size: inherit; 63 | } 64 | 65 | footer.page-footer { 66 | margin: 0; 67 | } 68 | -------------------------------------------------------------------------------- /deployment/prod/cloudformation/serverless/src/sqs_lambda_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os import getenv 3 | 4 | import urllib3 5 | 6 | http = urllib3.PoolManager() 7 | 8 | 9 | def handler(event, context): 10 | """Lambda function to forward SQS messages to Slack""" 11 | try: 12 | # Initialize variables 13 | url = getenv("SLACK_WEBHOOK_URL") 14 | sqs_messages = [] 15 | for record in event["Records"]: 16 | payload = record["body"] 17 | print(f"SQS paylod: {payload}") 18 | sqs_messages.append(payload) 19 | 20 | # Check slack configuration 21 | if not url: 22 | print(f"Invalid slack configuration. SQS message: {sqs_messages}") 23 | return 24 | 25 | # Post up to 10 SQS record(s) to Slack 26 | msg = { 27 | "channel": "#general", 28 | "username": "SQS", 29 | "text": str(sqs_messages), 30 | "icon_emoji": ":cloud:", 31 | } 32 | encoded_msg = json.dumps(msg).encode("utf-8") 33 | resp = http.request("POST", url, body=encoded_msg) 34 | print( 35 | {"message": sqs_messages, "status_code": resp.status, "response": resp.data} 36 | ) 37 | 38 | except Exception as excpt: 39 | print(f"Execution failed... {excpt}") 40 | -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | # By default, this file configure environment variables so that Django can be run locally 2 | # For additional variables used in docker deployment, please set environment variables with the 3 | # docker-compose.yml and config.py files 4 | 5 | # ---------------------------------------------------- 6 | # 7 | # DJANGO SETTINGS 8 | # 9 | # ---------------------------------------------------- 10 | SECRET_KEY='azxey(r8ieohsd1qc93j*%@+1+@-c&kwbgugz2ojvb@sj=!4*c' 11 | 12 | # ---------------------------------------------------- 13 | # 14 | # POSTGRESQL SETTINGS 15 | # 16 | # ---------------------------------------------------- 17 | POSTGRES_HOST=postgres 18 | POSTGRES_PORT=5432 19 | POSTGRES_DB=portfoliodb 20 | POSTGRES_USER=postgres 21 | POSTGRES_PASSWORD=postgres 22 | 23 | # ---------------------------------------------------- 24 | # 25 | # REDIS SETTINGS 26 | # 27 | # ---------------------------------------------------- 28 | REDIS_ENDPOINT=redis:6379 29 | 30 | # ---------------------------------------------------- 31 | # 32 | # AWS SNS Topic to forward ContactForm messages to 33 | # 34 | # ---------------------------------------------------- 35 | SNS_TOPIC_ARN= 36 | 37 | # ---------------------------------------------------- 38 | # 39 | # AWS SES Identity for email notifications to registered users 40 | # 41 | # ---------------------------------------------------- 42 | SES_IDENTITY_ARN= -------------------------------------------------------------------------------- /deployment/dev/terraform/modules/ec2/variables.tf: -------------------------------------------------------------------------------- 1 | variable "ami" { 2 | type = string 3 | description = "The id of the machine image (AMI) to use for the server." 4 | default = null 5 | 6 | validation { 7 | condition = can(regex("^ami-", var.ami)) 8 | error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"." 9 | } 10 | } 11 | 12 | variable "instance_type" { 13 | description = "The type of instance to start." 14 | type = string 15 | default = "t2.micro" 16 | validation { 17 | condition = can(regex("^t2.micro$", var.instance_type)) 18 | error_message = "The instance_type value must match the allowed pattern(s)." 19 | } 20 | } 21 | 22 | variable "key_name" { 23 | description = "The key name to use for the instance." 24 | type = string 25 | default = "" 26 | } 27 | 28 | variable "vpc_security_group_ids" { 29 | description = "A list of security group IDs to associate with." 30 | type = list(string) 31 | default = null 32 | } 33 | 34 | variable "tags" { 35 | description = "A mapping of tags to assign to the resource." 36 | type = map(string) 37 | default = {} 38 | } 39 | 40 | variable "iam_instance_profile" { 41 | description = "The IAM Instance Profile to launch the instance with. Specified as the name of the Instance Profile." 42 | type = string 43 | default = "" 44 | } -------------------------------------------------------------------------------- /app/main/templates/main/includes/navbar.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 28 | 29 | -------------------------------------------------------------------------------- /deployment/prod/cloudformation/parameters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ParameterKey": "ASGCPUTargetValue", 4 | "ParameterValue": "60" 5 | }, 6 | { 7 | "ParameterKey": "ASGDesiredCapacity", 8 | "ParameterValue": "1" 9 | }, 10 | { 11 | "ParameterKey": "ACMPublicSSLCertArn", 12 | "ParameterValue": "arn:aws:acm:us-east-1:091361846328:certificate/159956e1-4993-4268-a3c6-23ba05d4abca" 13 | }, 14 | { 15 | "ParameterKey": "EC2LatestLinuxAmiId", 16 | "ParameterValue": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2" 17 | }, 18 | { 19 | "ParameterKey": "EC2InstanceType", 20 | "ParameterValue": "t2.micro" 21 | }, 22 | { 23 | "ParameterKey": "EC2VolumeSize", 24 | "ParameterValue": "8" 25 | }, 26 | { 27 | "ParameterKey": "R53HostedZoneName", 28 | "ParameterValue": "tari.kitchen" 29 | }, 30 | { 31 | "ParameterKey": "SESIdentityArn", 32 | "ParameterValue": "arn:aws:ses:eu-west-2:091361846328:identity/tari.kitchen" 33 | }, 34 | { 35 | "ParameterKey": "SSMParamSlackWebhookUrl", 36 | "ParameterValue": "/SLACK/INCOMING_WEBHOOK_URL" 37 | }, 38 | { 39 | "ParameterKey": "SSMParamNameRdsPostgresPassword", 40 | "ParameterValue": "/RDS/POSTGRES_PASSWORD/SECURE" 41 | }, 42 | { 43 | "ParameterKey": "SubnetListStr", 44 | "ParameterValue": "subnet-02e899e8f94a13180,subnet-0a476349af847e45a" 45 | }, 46 | { 47 | "ParameterKey": "VpcId", 48 | "ParameterValue": "vpc-0e47d190752f51544" 49 | }, 50 | { 51 | "ParameterKey": "SetR53SubDomainAsStackName", 52 | "ParameterValue": "/DEPLOYMENT/R53_SUB_DOMAIN" 53 | } 54 | ] -------------------------------------------------------------------------------- /app/main/templates/main/includes/item_section.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |
4 | 5 |
6 |
7 |
8 |
9 |
10 |

{{item.item_name}}

11 |

{{item.date_published}}

12 |
13 | 14 |
15 |
16 |
17 | {{item.content|safe}} 18 |
19 |
20 | 21 |
22 |
23 |
    24 | {% for item in sidebar %} 25 | {% if forloop.counter0 == this_item_idx %} 26 |
  • 27 |
    visibility{{item.item_name}}
    28 |
  • 29 | {% else %} 30 |
  • 31 |
    keyboard_arrow_right{{item.item_name}}
    32 |
    33 |

    34 |
    35 |
  • 36 | {% endif %} 37 | {% endfor %} 38 |
39 |
40 | 41 |
42 |
-------------------------------------------------------------------------------- /app/main/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines Django forms for user to register 3 | and for the `contact us` page 4 | """ 5 | 6 | from django import forms 7 | from django.contrib.auth.forms import UserCreationForm 8 | from django.contrib.auth.models import User 9 | 10 | 11 | class ContactForm(forms.Form): 12 | """Contact Form allowing a user to send a message""" 13 | 14 | name = forms.CharField(required=True) 15 | contact_email = forms.EmailField(required=True) 16 | subject = forms.CharField(required=True) 17 | message = forms.CharField(widget=forms.Textarea(), required=True, max_length=2048,) 18 | 19 | def json(self): 20 | """Returns form fields as a dictionary.""" 21 | return dict( 22 | name=self.name, 23 | contact_email=self.contact_email, 24 | subject=self.subject, 25 | message=self.message, 26 | ) 27 | 28 | 29 | class NewUserForm(UserCreationForm): 30 | """ 31 | Extends the UserCreationForm class to add an Email field 32 | for user registration form 33 | """ 34 | 35 | email = forms.EmailField(required=True) 36 | 37 | def __init__(self, *args, **kwargs): 38 | """Removes ugly fields hints (help_text)""" 39 | super().__init__(*args, **kwargs) 40 | for fieldname in ("username", "password1", "password2"): 41 | self.fields[fieldname].help_text = None 42 | 43 | def save(self, commit=True): 44 | user = super().save(commit=False) 45 | user.email = self.cleaned_data["email"] 46 | user.save() 47 | return user 48 | 49 | class Meta: 50 | model = User 51 | fields = ("username", "email", "password1", "password2") 52 | -------------------------------------------------------------------------------- /app/main/templates/main/register.html: -------------------------------------------------------------------------------- 1 | {% extends "main/index.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 | 8 |
9 |

Sign up person_pin

10 |
11 |
12 |
By registering, you will receive updates on the latest content.
13 |

14 |
15 | {% csrf_token %} 16 | {% load materializecss %} 17 | {{ form|materializecss }} 18 |

19 |

20 |    Après l'inscription, vous allez recevoir un email en anglais de la part de "Amazon Web Services". 21 |
22 |    Merci de cliquer sur le lien pour confirmer votre email et recevoir les notifications de la Tari-Newsletter. 23 |

24 |
25 |
26 | 29 |
30 | 31 |
32 |

33 | If you already have an account login instead. 34 | 35 |
36 |
37 |
38 | 39 |


40 | 41 | {% endblock %} 42 | 43 | {% block footer %} 44 | {% include "main/includes/footer_small.html" %} 45 | {% endblock %} -------------------------------------------------------------------------------- /deployment/dev/terraform/variables.tf: -------------------------------------------------------------------------------- 1 | # General 2 | variable "provisioning_logs" { 3 | description = "Local path to the provisioning logs file." 4 | type = string 5 | default = null 6 | } 7 | 8 | variable "tag_name" { 9 | description = "Name to tag aws resources with." 10 | type = string 11 | default = null 12 | } 13 | 14 | # EC2 15 | variable "instance_count" { 16 | description = "Number of instances to launch." 17 | type = number 18 | default = 1 19 | } 20 | 21 | variable "environment" { 22 | type = string 23 | description = "placeholder" 24 | default = "dev" 25 | 26 | validation { 27 | condition = can(regex("^(dev|prod)$", var.environment)) 28 | error_message = "The environment must be set to dev or prod to be valid." 29 | } 30 | } 31 | 32 | variable "instance_type" { 33 | type = map(string) 34 | description = "A map to compute the instance type to launch based on the environment" 35 | default = { 36 | dev = "t2.micro" 37 | prod = "t2.large" 38 | } 39 | } 40 | 41 | variable "aws_pem_key_name" { 42 | description = "The key name to use for the instance." 43 | type = string 44 | default = "" 45 | } 46 | 47 | variable "iam_instance_profile" { 48 | description = "The IAM Instance Profile to launch the instance with. Specified as the name of the Instance Profile." 49 | type = string 50 | default = "" 51 | } 52 | 53 | # Security Groups 54 | variable "sg_ports" { 55 | type = list(number) 56 | description = "list of ingress ports" 57 | default = [80, 443, 8080] 58 | } 59 | 60 | variable "vpn_ip" { 61 | type = string 62 | description = "your IP address for ssh access" 63 | default = null 64 | } 65 | 66 | -------------------------------------------------------------------------------- /deployment/prod/cloudformation/serverless/src/codedeploy_lambda_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lambda function to push custom metrics, FAILURE or SUCCESS, 3 | based on CodeDeploy deployment state change notification 4 | """ 5 | import datetime 6 | from os import getenv 7 | 8 | import boto3 9 | 10 | 11 | # pylint: disable=unused-argument 12 | def handler(event, context): 13 | """Lambda function to push custom metrics, FAILURE or SUCCESS, 14 | based on CodeDeploy deployment state change notification""" 15 | try: 16 | # Retrieve environment variables 17 | dimension_name = getenv("CODEDEPLOY_DIMENSION_NAME") 18 | metric_name = getenv("CODEDEPLOY_METRIC_NAME") 19 | if not dimension_name or not metric_name: 20 | return "CODEDEPLOY_DIMENSION_NAME or CODEDEPLOY_METRIC_NAME not set" 21 | 22 | # Get deployment state from CodeDeploy event 23 | deployment_state = event["detail"]["state"] 24 | print(f"Deployment state: {deployment_state}") 25 | 26 | # Pushing custom metric to CW 27 | response = boto3.client("cloudwatch").put_metric_data( 28 | MetricData=[ 29 | { 30 | "MetricName": metric_name, 31 | "Dimensions": [{"Name": dimension_name, "Value": deployment_state}], 32 | "Unit": "None", 33 | "Value": 1, 34 | "Timestamp": datetime.datetime.now(), 35 | }, 36 | ], 37 | Namespace="CodeDeployDeploymentStates", 38 | ) 39 | print(f"Response from CW service: {response}") 40 | return response 41 | # pylint: disable=broad-except 42 | except Exception as excpt: 43 | print(f"Execution failed... {excpt}") 44 | return None 45 | -------------------------------------------------------------------------------- /app/tests/test_views/test_category.py: -------------------------------------------------------------------------------- 1 | """This module defines tests manipulating category objects from main.views""" 2 | from http import HTTPStatus 3 | 4 | import pytest 5 | from django.db.models.query import QuerySet 6 | from django.urls import reverse 7 | 8 | from helpers.constants import TemplateNames 9 | 10 | 11 | @pytest.mark.django_db(transaction=True) 12 | class TestViewCategory: 13 | """Tests for the category page""" 14 | 15 | @pytest.mark.integration 16 | # pylint: disable=no-self-use 17 | def test_404_no_category_in_db(self, client): 18 | """Test that 404 is handled when no category exist""" 19 | # When: GET request on the view category page when no category exist 20 | response = client.get(reverse("categories_view")) 21 | # Then: User redirect to the go-back-home template, with a 200 22 | assert TemplateNames.GO_BACK_HOME.value in [t.name for t in response.templates] 23 | assert response.status_code == HTTPStatus.OK.value 24 | assert response.context["code_handled"] == HTTPStatus.NOT_FOUND.value 25 | 26 | @pytest.mark.integration 27 | # pylint: disable=unused-argument 28 | # pylint: disable=no-self-use 29 | def test_view_category(self, client, load_default_category): 30 | """Test the Category page when database contains one category object""" 31 | # When: GET request on the view category page when a category DOES exist 32 | response = client.get(reverse("categories_view")) 33 | # Then: Category page template is rendered with the list of categories 34 | assert TemplateNames.CATEGORIES.value in [t.name for t in response.templates] 35 | assert response.status_code == HTTPStatus.OK.value 36 | assert isinstance(response.context["all_categories_list"], QuerySet) 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VS Code ignores 2 | .DS_Store 3 | 4 | .vscode/* 5 | !.vscode/settings.json 6 | !.vscode/tasks.json 7 | !.vscode/launch.json 8 | !.vscode/extensions.json 9 | *.code-workspace 10 | 11 | # Ignore builds 12 | *bin/ 13 | *nested-stacks.yaml 14 | 15 | # Local History for Visual Studio Code 16 | .history/ 17 | # default ignore 18 | __pycache__/ 19 | .pytest_cache/ 20 | .idea/ 21 | .vscode/ 22 | node_modules/ 23 | */frontend/build/ 24 | .coverage* 25 | htmlcov/ 26 | *.code-workspace 27 | *.ipynb 28 | *.swp 29 | *.egg-info 30 | .DS_Store 31 | 32 | # python packaging 33 | dist/ 34 | 35 | # Django static files 36 | *staticfiles/ 37 | *mediafiles/ 38 | 39 | # Generated documentation 40 | 41 | # Logs 42 | *.log 43 | 44 | ### Terraform + Ansible 45 | 46 | # Ansible staging inventory which is automatically create/destroyed by Terraform 47 | *hosts 48 | 49 | # Local .terraform directories 50 | **/.terraform/* 51 | 52 | # .tfstate files 53 | *.tfstate 54 | *.tfstate.* 55 | 56 | # Crash log files 57 | *crash.log 58 | 59 | # Any log files 60 | *.log 61 | 62 | # Exclude all .tfvars files, which are likely to contain sentitive data, such as 63 | # password, private keys, and other secrets. These should not be part of version 64 | # control as they are data points which are potentially sensitive and subject 65 | # to change depending on the environment. 66 | # 67 | *.tfvars 68 | 69 | # Ignore override files as they are usually used to override resources locally and so 70 | # are not checked in 71 | override.tf 72 | override.tf.json 73 | *_override.tf 74 | *_override.tf.json 75 | 76 | # Include override files you do wish to add to version control using negated pattern 77 | # 78 | # !example_override.tf 79 | 80 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 81 | # example: *tfplan* 82 | 83 | # Ignore CLI configuration files 84 | .terraformrc 85 | terraform.rc 86 | -------------------------------------------------------------------------------- /deployment/webapp.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim-buster 2 | 3 | # Change default shell to /bin/bash 4 | SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] 5 | 6 | ARG APP_WHEEL=dist/*.whl 7 | ARG APP_DIR=app 8 | ARG MOUNT_DIR=mounts 9 | ARG STARTUP_SCRIPT=deployment/config/startup_server.sh 10 | ARG USERNAME="portfoliouser" 11 | 12 | ENV PATH="/opt/venv/bin:${PATH}" \ 13 | PYTHONPATH="/home/${USERNAME}/${APP_DIR}/" \ 14 | DJANGO_SETTINGS_MODULE="portfolio.settings" \ 15 | # prevents python creating .pyc files 16 | PYTHONDONTWRITEBYTECODE=1 \ 17 | PYTHONUNBUFFERED=1 18 | 19 | # hadolint ignore=DL3008 20 | RUN apt-get update && \ 21 | # Add additional basic packages. 22 | # * gcc libpq-dev python3-dev: psycopg2 source dependencies 23 | # * curl: to healthcheck services with http response 24 | # * vim: editing files 25 | # * procps: useful utilities such as ps, top, vmstat, pgrep,... 26 | apt-get install -yq --no-install-recommends gcc libpq-dev python3-dev curl vim procps 27 | # Clean the apt cache 28 | # rm -rf /var/lib/apt/lists/* 29 | 30 | # Copy and install dependencies 31 | # hadolint ignore=DL3020 32 | ADD $APP_WHEEL /tmp 33 | # hadolint ignore=DL3013 34 | RUN python -m venv /opt/venv && \ 35 | # Install python dependencies 36 | pip install /tmp/*.whl && \ 37 | rm -rf /tmp/* 38 | 39 | # Copy application code and startup script 40 | COPY ${APP_DIR}/ /home/${USERNAME}/${APP_DIR} 41 | COPY ${STARTUP_SCRIPT} /home/${USERNAME}/${MOUNT_DIR}/ 42 | 43 | # Add user 44 | RUN adduser --disabled-password --gecos "" "${USERNAME}" && \ 45 | chown -R "${USERNAME}":"${USERNAME}" /home 46 | USER ${USERNAME} 47 | 48 | # Webserver will be running on this port 49 | EXPOSE 8080 50 | 51 | HEALTHCHECK --interval=30s --timeout=30s --retries=3 CMD curl --fail http://localhost:8080 || exit 1 52 | 53 | # hadolint ignore=DL3000 54 | WORKDIR "/home/${USERNAME}/" 55 | 56 | CMD ["./mounts/startup_server.sh"] -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/scripts/5_validate_service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | # Verify the deployment was completed successfully 4 | 5 | # Helper functions 6 | function check_service_health() { 7 | container_id=$(docker ps --filter "ancestor=$1" -qa) 8 | # Check if container exists 9 | if [[ -z "${container_id}" ]]; then 10 | echo "❌ Container $1 is not running.. Aborting!" 11 | exit 1 12 | fi; 13 | # Wait for the container to fully start up 14 | until [[ $(get_service_health "${container_id}") != "starting" ]]; do 15 | sleep 1 16 | done; 17 | # Check if container status shows healthy 18 | if [[ $(get_service_health "${container_id}") != "healthy" ]]; then 19 | echo "❌ $1 failed health check" 20 | exit 1 21 | fi; 22 | echo "Container running from $1 is healthy 🍀" 23 | } 24 | 25 | function get_service_health() { 26 | echo "$1" | xargs -I ID docker inspect -f '{{if .State.Running}}{{ .State.Health.Status }}{{end}}' ID 27 | } 28 | 29 | # Source environment variables set from cfn-init 30 | source /root/.bashrc 31 | 32 | echo "Get the image name from the SSM Parameter service" 33 | IMAGE_NAME=$(aws ssm get-parameter \ 34 | --name "/CODEDEPLOY/DOCKER_IMAGE_NAME" \ 35 | --query "Parameter.Value" \ 36 | --output text \ 37 | --region "${AWS_REGION}") 38 | 39 | echo "Verify DB connection" 40 | [[ ! -z "${RDS_POSTGRES_HOST}" ]] || echo "RDS_POSTGRES_HOST not set" 41 | [[ ! -z "${RDS_POSTGRES_PASSWORD}" ]] || echo "RDS_POSTGRES_PASSWORD not set" 42 | psql -d "postgresql://postgres:${RDS_POSTGRES_PASSWORD}@${RDS_POSTGRES_HOST}/portfoliodb" -c "select now()" 43 | 44 | echo "Verify Redis connection" 45 | /redis-stable/src/redis-cli -c -h ${ELASTICACHE_REDIS_HOST} -p ${ELASTICACHE_REDIS_PORT} ping 46 | 47 | echo "Checking health for container running from $IMAGE_NAME" 48 | check_service_health "$IMAGE_NAME" 49 | echo "✅ All services are up and healthy!" 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | """Main configuration parameters for FastAPI and Lambda powertools""" 2 | import logging 3 | from distutils.util import strtobool 4 | from os import getenv 5 | from pathlib import Path 6 | 7 | from starlette.config import Config 8 | from starlette.datastructures import Secret 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | # Paths 13 | APP_DIR = Path(__file__).resolve().parent 14 | ENV_PATH = APP_DIR / ".env" 15 | logger.info(f"Loading Django configs environment variables from {ENV_PATH}") 16 | 17 | config = Config(env_file=ENV_PATH) 18 | 19 | # ======================= SETTINGS.PY ========================= 20 | 21 | # General settings 22 | DEBUG: bool = bool(strtobool(getenv("DEBUG", "True"))) 23 | SECRET_KEY: Secret = getenv("SECRET_KEY", config("SECRET_KEY", cast=Secret)) 24 | STATIC_FILES_PATH: str = "staticfiles/" 25 | MEDIA_FILES_PATH: str = "mediafiles/" 26 | 27 | # Postgres 28 | POSTGRES_HOST: str = getenv( 29 | "POSTGRES_HOST", config("POSTGRES_HOST", default="localhost") 30 | ) 31 | POSTGRES_PASSWORD: Secret = getenv( 32 | "POSTGRES_PASSWORD", config("POSTGRES_PASSWORD", cast=Secret, default="postgres") 33 | ) 34 | POSTGRES_DB: str = getenv("POSTGRES_DB", config("POSTGRES_DB", default="portfoliodb")) 35 | POSTGRES_PORT: int = getenv( 36 | "POSTGRES_PORT", config("POSTGRES_PORT", cast=int, default=5432) 37 | ) 38 | POSTGRES_USER: str = getenv( 39 | "POSTGRES_USER", config("POSTGRES_USER", default="postgres") 40 | ) 41 | 42 | # Redis Cache 43 | # Get from environment variable, for example if ElastiCache is used, 44 | # Otherwise assume Redis running in a docker container named "redis" 45 | REDIS_ENDPOINT: str = getenv( 46 | "REDIS_ENDPOINT", config("REDIS_ENDPOINT", default="localhost:6379") 47 | ) 48 | CACHE_TTL: int = int(getenv("CACHE_TTL", "60")) 49 | 50 | # Static files served from AWS S3 Bucket 51 | STATICFILES_BUCKET: str = getenv("STATICFILES_BUCKET") 52 | AWS_REGION: str = getenv("AWS_REGION", "eu-west-2") 53 | AWS_S3_CUSTOM_DOMAIN: str = getenv( 54 | "AWS_S3_CUSTOM_DOMAIN", f"s3.{AWS_REGION}.amazonaws.com/{STATICFILES_BUCKET}" 55 | ) # E.g. tari.kitchen 56 | 57 | # Forward ContactForm emails to AWS SNS Topic 58 | SNS_TOPIC_ARN: str = getenv("SNS_TOPIC_ARN", config("SNS_TOPIC_ARN", default=None)) 59 | 60 | # SES identity for email notifications 61 | SES_IDENTITY_ARN: str = getenv( 62 | "SES_IDENTITY_ARN", config("SES_IDENTITY_ARN", default=None) 63 | ) 64 | -------------------------------------------------------------------------------- /app/portfolio/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines additional Django configurations and can be used 3 | to override settings from setting/base.py. 4 | """ 5 | 6 | from .base import * 7 | 8 | 9 | DEBUG = config.DEBUG 10 | print(f"Loading Django settings (DEBUG={DEBUG})") 11 | 12 | ENABLE_LOGIN_REQUIRED_MIXIN = False 13 | 14 | # Forward ContactForm emails to AWS SNS Topic 15 | SNS_TOPIC_ARN = config.SNS_TOPIC_ARN 16 | 17 | # DATABASE 18 | print(f"DB backend config: Host={config.POSTGRES_HOST}") 19 | DATABASES = { 20 | "default": { 21 | "ENGINE": "django.db.backends.postgresql", 22 | "NAME": config.POSTGRES_DB, 23 | "USER": config.POSTGRES_USER, 24 | "PASSWORD": config.POSTGRES_PASSWORD, 25 | "HOST": config.POSTGRES_HOST, 26 | "PORT": config.POSTGRES_PORT, 27 | } 28 | } 29 | 30 | # CACHE 31 | # https://testdriven.io/blog/django-caching/ 32 | CACHE_TTL = config.CACHE_TTL 33 | print(f"Redis Cache config: Endpoint={config.REDIS_ENDPOINT}, TTL={CACHE_TTL}s") 34 | CACHES = { 35 | "default": { 36 | "BACKEND": "django_redis.cache.RedisCache", 37 | "LOCATION": f"redis://{config.REDIS_ENDPOINT}", 38 | "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, 39 | } 40 | } 41 | 42 | # FILE STORAGE - s3 static settings & s3 public media settings 43 | if not config.STATICFILES_BUCKET: 44 | print("Using local filesystem to serve static files") 45 | STATIC_URL = config.STATIC_FILES_PATH 46 | STATIC_ROOT = os.path.join(BASE_DIR, STATIC_URL) 47 | MEDIA_URL = config.MEDIA_FILES_PATH 48 | MEDIA_ROOT = os.path.join(BASE_DIR, MEDIA_URL) 49 | STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) 50 | else: 51 | print(f"Using S3 Bucket {config.STATICFILES_BUCKET} to serve static files") 52 | # Extra variables for AWS 53 | AWS_STORAGE_BUCKET_NAME = config.STATICFILES_BUCKET 54 | AWS_S3_CUSTOM_DOMAIN = config.AWS_S3_CUSTOM_DOMAIN 55 | AWS_DEFAULT_REGION = config.AWS_REGION 56 | AWS_DEFAULT_ACL = None 57 | AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} 58 | # Django variables 59 | STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{config.STATIC_FILES_PATH}/" 60 | STATICFILES_STORAGE = "main.storage_backends.StaticStorage" 61 | MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{config.MEDIA_FILES_PATH}/" 62 | DEFAULT_FILE_STORAGE = "main.storage_backends.PublicMediaStorage" 63 | STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) 64 | -------------------------------------------------------------------------------- /app/main/templates/main/includes/footer.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | -------------------------------------------------------------------------------- /app/main/mixins.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines common functionalities across Django models and Views 3 | """ 4 | 5 | import logging 6 | import sys 7 | from io import BytesIO 8 | 9 | from django.conf import settings 10 | from django.contrib.auth.views import redirect_to_login 11 | from django.core.files.uploadedfile import InMemoryUploadedFile 12 | from django.db.models.fields.files import ImageFieldFile 13 | from PIL import Image 14 | 15 | from helpers.constants import CROP_SIZE, THUMBNAIL_SIZE 16 | 17 | 18 | class RequireLoginMixin: 19 | """Add this Mixin in django class views to enforce logging in""" 20 | 21 | def dispatch(self, request, *args, **kwargs): 22 | """ 23 | Overrides the dispatch method, to check if user is logged in 24 | before returning the HTTP response. 25 | """ 26 | if settings.ENABLE_LOGIN_REQUIRED_MIXIN and not request.user.is_authenticated: 27 | return redirect_to_login(next=request.get_full_path(), login_url="/login") 28 | return super().dispatch(request, *args, **kwargs) 29 | 30 | 31 | class BaseModelMixin: 32 | """Base Class providing helper functions for Django Models""" 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | # pylint: disable=no-self-use 37 | def resize_image( 38 | self, uploaded_image: ImageFieldFile, suffix: str = None 39 | ) -> ImageFieldFile: 40 | """ 41 | Performs the following operation on a given image: 42 | - Thumbmail: returns an image that fits inside of a given size 43 | - Crop: Cut image borders to fit a given size 44 | """ 45 | 46 | img_temp = Image.open(uploaded_image) 47 | 48 | img_temp.thumbnail(THUMBNAIL_SIZE) 49 | width, height = img_temp.size 50 | 51 | left = (width - CROP_SIZE.width) / 2 52 | top = (height - CROP_SIZE.height) / 2 53 | right = (width + CROP_SIZE.width) / 2 54 | bottom = (height + CROP_SIZE.height) / 2 55 | 56 | img_temp = img_temp.crop((left, top, right, bottom)) 57 | img_temp = img_temp.convert("RGB") 58 | img_temp.save(output_io_stream := BytesIO(), format="JPEG", quality=90) 59 | output_io_stream.seek(0) 60 | new_filename = f"{uploaded_image.name.split('.')[0]}{suffix}.jpg" 61 | uploaded_image = InMemoryUploadedFile( 62 | output_io_stream, 63 | "ImageField", 64 | new_filename, 65 | "image/jpeg", 66 | sys.getsizeof(output_io_stream), 67 | None, 68 | ) 69 | return uploaded_image 70 | -------------------------------------------------------------------------------- /deployment/prod/codedeploy-app/scripts/4_start_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | # Script to restart services that were stopped during ApplicationStop 4 | 5 | # Source environment variables set from cfn-init 6 | source /root/.bashrc 7 | 8 | # Stop the sample Apache server if it's running 9 | # Stopping here because the ApplicationStop lifecycle hook 10 | # is never run on a new instance from a scale out event 11 | systemctl stop httpd.service || true 12 | 13 | 14 | echo "Get the image name and debug bool from the SSM Parameter service" 15 | IMAGE_NAME=$(aws ssm get-parameter \ 16 | --name "/CODEDEPLOY/DOCKER_IMAGE_NAME" \ 17 | --query "Parameter.Value" \ 18 | --output text \ 19 | --region "${AWS_REGION}") 20 | DEBUG=$(aws ssm get-parameter \ 21 | --name "/CODEDEPLOY/DEBUG_DEMO" \ 22 | --query "Parameter.Value" \ 23 | --output text \ 24 | --region "${AWS_REGION}") 25 | CONTAINER_NAME=myapp 26 | 27 | echo "Pulling and running docker container for ${IMAGE_NAME}" 28 | docker pull ${IMAGE_NAME} 29 | 30 | echo 'Create app-logs volume and change permissions to non-root user' 31 | docker volume create app-logs 32 | chown -R 1000:1000 /var/lib/docker/volumes/ 33 | 34 | echo 'Start webapp container' 35 | docker run -d \ 36 | -p 80:8080 \ 37 | --name=${CONTAINER_NAME} \ 38 | --restart=no \ 39 | --log-driver=awslogs \ 40 | --log-opt awslogs-group=${DOCKER_LOGS_LOG_GROUP_NAME} \ 41 | --mount type=bind,source=/home/ec2-user/mounts/startup_server.sh,target=/home/portfoliouser/mounts/ \ 42 | --mount type=volume,source=app-logs,target=/home/portfoliouser/app/logs/ \ 43 | --env POSTGRES_HOST=${RDS_POSTGRES_HOST} \ 44 | --env POSTGRES_PASSWORD=${RDS_POSTGRES_PASSWORD} \ 45 | --env REDIS_ENDPOINT=${ELASTICACHE_REDIS_HOST}:${ELASTICACHE_REDIS_PORT} \ 46 | --env STATICFILES_BUCKET=${STATICFILES_BUCKET} \ 47 | --env AWS_S3_CUSTOM_DOMAIN=${WEBAPP_DOMAIN} \ 48 | --env SNS_TOPIC_ARN=${DJANGO_APP_SNS_TOPIC_ARN} \ 49 | --env SES_IDENTITY_ARN=${DJANGO_APP_SES_IDENTITY_ARN} \ 50 | --env DEBUG=${DEBUG} \ 51 | ${IMAGE_NAME} 52 | 53 | echo "Write instance details to the footer.html file" 54 | EC2_INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id) 55 | EC2_AZ=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone) 56 | HTML_PAGE=/home/portfoliouser/app/main/templates/main/includes/footer.html 57 | sudo docker exec $CONTAINER_NAME sed -i "s/aws-ec2-details-placeholder/Running on AWS | $(hostname -f) | $EC2_INSTANCE_ID | $EC2_AZ/g" $HTML_PAGE -------------------------------------------------------------------------------- /app/main/templates/main/includes/icon_section.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
3 |
4 |
5 | 6 | 7 | {% comment %} All icons at https://materializecss.com/icons.html {% endcomment %} 8 | 9 |
10 |
11 |
12 | star 13 | star 14 | star 15 | star 16 | star 17 |
18 |
Un voyage des papilles
19 |
20 |

21 | Une belle occasion de (re)découvrir de vrais plats au curry. Des goûts raffinés, et des produits frais et goûteux. Une explosion de saveurs en bouche. - Chmie 22 |

23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 | star 31 | star 32 | star 33 | star 34 | star 35 |
36 |
À faire absolument!
37 |
38 |

39 | Très belle découverte, le personnel est très agréable et d’excellent conseil - les mesures COVID sont en place - je recommande vivement. - Moyne 40 |

41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 | star 49 | star 50 | star 51 | star 52 | star_half 53 |
54 |
Dîner de Noël très réussi
55 |
56 |

57 | Un super dîner de Noël en famille très réussi, au coeur de la Vendée ! La maison est très accueillante et la piscine au top. Je compte y retourner bientôt :). - JM 58 |

59 |
60 |
61 |
62 | 63 |
64 |
65 |
66 | -------------------------------------------------------------------------------- /deployment/dev/ansible/roles/common/tasks/7_deploy.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Check if previous installation exists 4 | stat: 5 | path: "{{ full_path_repositories_dir }}" 6 | register: prev_inst_dir 7 | 8 | - name: Delete a previous installation directory 9 | file: 10 | path: "{{ full_path_repositories_dir }}" 11 | state: absent 12 | when: prev_inst_dir.stat.isdir is defined and prev_inst_dir.stat.isdir 13 | 14 | - name: Create install directories (if they do not exist) and change ownership 15 | file: 16 | path: "{{ item }}" 17 | state: directory 18 | mode: "0755" 19 | owner: "{{ user }}" 20 | group: "{{ user }}" 21 | loop: 22 | - "{{ full_path_repositories_dir }}" 23 | 24 | - name: Copy github ssh keys from local host to remote (~/.ssh/id_rsa) 25 | copy: 26 | src: /Users/guillaume.bournique/.ssh/{{ item }} 27 | dest: "{{ user_path }}/.ssh/{{ item }}" 28 | mode: 0400 29 | loop: 30 | - "id_rsa.pub" 31 | - "id_rsa" 32 | 33 | - name: Clone the code django repository 34 | git: 35 | repo: "{{ git_repository }}" 36 | dest: "{{ full_path_django_repo_dir }}" 37 | accept_hostkey: yes 38 | key_file: "{{ user_path }}/.ssh/id_rsa" 39 | update: no 40 | 41 | - name: Checkout to branch {{ git_branch_name }} 42 | command: git checkout {{ git_branch_name }} 43 | ignore_errors: yes 44 | args: 45 | chdir: "{{ full_path_django_repo_dir }}" 46 | 47 | - name: Remove github ssh keys from remote host 48 | file: 49 | path: "{{ user_path }}/.ssh/{{ item }}" 50 | state: absent 51 | loop: 52 | - "id_rsa.pub" 53 | - "id_rsa" 54 | 55 | - name: Compose up postgres and redis databases 56 | docker_compose: 57 | project_src: "{{ full_path_django_repo_dir }}" 58 | pull: yes 59 | state: present 60 | vars: 61 | ansible_python_interpreter: /usr/bin/python3 62 | 63 | - name: Get docker image tag from poetry project version 64 | become_user: "{{ user }}" 65 | command: poetry version -s 66 | args: 67 | chdir: "{{ full_path_django_repo_dir }}" 68 | register: image_tag 69 | 70 | - name: Run dockerised django application 71 | community.docker.docker_container: 72 | name: webapp 73 | state: started 74 | detach: yes 75 | ports: ["8080:8080"] 76 | restart: no 77 | networks: [name: global-network] 78 | env: 79 | DEBUG: "True" 80 | POSTGRES_HOST: "postgres" 81 | POSTGRES_PASSWORD: "postgres" 82 | REDIS_ENDPOINT: "redis:6379" 83 | SNS_TOPIC_ARN: "" 84 | SES_IDENTITY_ARN: "" 85 | image: "{{ docker_image_name }}:{{ image_tag.stdout }}" 86 | vars: 87 | ansible_python_interpreter: /usr/bin/python3 88 | -------------------------------------------------------------------------------- /deployment/dev/terraform/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "eu-west-2" 3 | profile = "myaws" 4 | } 5 | 6 | data "aws_ami" "app_ami" { 7 | # Get the latest Amazon Linux 2 AMI 8 | most_recent = true 9 | owners = ["amazon"] 10 | 11 | filter { 12 | name = "name" 13 | values = ["amzn2-ami-hvm*"] 14 | } 15 | } 16 | 17 | locals { 18 | time = formatdate("DD MMM YYYY hh:mm ZZZ", timestamp()) 19 | common_tags = { 20 | Name = var.tag_name 21 | LastUpdatedAt = local.time 22 | } 23 | } 24 | 25 | module "mysecuritygroups" { 26 | # Call internal module to create security groups 27 | # Internal module allows to hardcode variables such as the VPN IP range 28 | source = "./modules/sg" 29 | sg_ports = var.sg_ports 30 | tags = local.common_tags 31 | vpn_ip = var.vpn_ip 32 | } 33 | 34 | module "myec2instances" { 35 | # Call internal module to create ec2 instances 36 | # Internal module allows to hardcode variables such as a hardened company AMI ID 37 | source = "./modules/ec2" 38 | count = var.instance_count 39 | ami = data.aws_ami.app_ami.id 40 | key_name = var.aws_pem_key_name 41 | instance_type = lookup(var.instance_type, var.environment, null) 42 | iam_instance_profile = var.iam_instance_profile 43 | vpc_security_group_ids = [module.mysecuritygroups.dynamic_sg_id, module.mysecuritygroups.ssh_sg_id] 44 | tags = local.common_tags 45 | } 46 | 47 | resource "local_file" "hosts_inventory_file" { 48 | # Generate Ansible inventory file 49 | depends_on = [ 50 | module.myec2instances 51 | ] 52 | content = templatefile("${path.module}/templates/staging_hosts.tpl", 53 | { 54 | public_ips = module.myec2instances.*.public_ip 55 | } 56 | ) 57 | filename = "${path.module}/../ansible/inventories/staging/hosts" 58 | } 59 | 60 | # Uncomment the blow below for Terraform to call Ansible playbooks 61 | # and provision instances automatically 62 | # resource "null_resource" "cluster_provisioning" { 63 | # # Call Ansible playbook to provision instances 64 | # depends_on = [ 65 | # local_file.hosts_inventory_file 66 | # ] 67 | # provisioner "local-exec" { 68 | # command = "sleep 60; source ../.env; ansible-playbook -i ../ansible/inventories ../ansible/staging.yaml --timeout 60" 69 | # } 70 | # } 71 | 72 | resource "null_resource" "logging" { 73 | # Call Ansible playbooks to provision instances 74 | depends_on = [ 75 | local_file.hosts_inventory_file 76 | ] 77 | count = length(module.myec2instances.*.arn) 78 | provisioner "local-exec" { 79 | command = "sleep 20; echo 'provisioned instance with public ip: ${element(module.myec2instances.*.public_ip, count.index)}' >> ${var.provisioning_logs}" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/main/templates/main/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 24 | 25 | 26 | 27 | 28 | 29 | Tari Kitchen 30 | 31 | {% load static %} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 63 | 64 | 65 | 66 | 67 | 68 |
69 | {% include "main/includes/navbar.html" %} 70 | {% block header %} 71 | {% endblock %} 72 |
73 | 74 |
75 | {% include "main/includes/messages.html" %} 76 | {% block content %} 77 | {% endblock %} 78 |
79 | 80 |
81 | {% block footer %} 82 | {% endblock %} 83 |
84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: autoflake 5 | name: autoflake 6 | stages: [commit] 7 | language: system 8 | entry: autoflake 9 | args: 10 | - '--in-place' 11 | - '--remove-all-unused-imports' 12 | - '--remove-unused-variables' 13 | - "--ignore-init-module-imports" 14 | types: [python] 15 | 16 | - id: isort 17 | name: isort 18 | stages: [commit] 19 | language: system 20 | entry: isort 21 | args: 22 | - "--line-width" 23 | - "88" 24 | - "--multi-line" 25 | - "3" 26 | - "--trailing-comma" 27 | - "--use-parentheses" 28 | types: [python] 29 | 30 | - id: black 31 | name: black 32 | stages: [commit] 33 | language: system 34 | entry: black 35 | types: [python] 36 | 37 | - id: pylint 38 | name: pylint 39 | stages: [commit] 40 | language: system 41 | entry: pylint 42 | args: ["app", "--fail-under", "9.5", "--load-plugins", "pylint_django"] 43 | types: [python] 44 | 45 | - id: yamllint 46 | name: yamllint 47 | stages: [commit] 48 | language: system 49 | entry: bash -c "yamllint . -d relaxed --no-warnings" 50 | types: [yaml] 51 | 52 | - id: cfnlint 53 | name: cfnlint 54 | stages: [commit] 55 | language: system 56 | entry: bash -c "cfn-lint deployment/prod/cloudformation/**/*.yaml --ignore-checks W3002" 57 | types: [yaml] 58 | 59 | - repo: git://github.com/antonbabenko/pre-commit-terraform 60 | rev: v1.46.0 61 | hooks: 62 | - id: terraform_fmt 63 | # - id: terraform_validate 64 | - id: terraform_docs 65 | - id: terraform_tflint 66 | args: 67 | - '--args=--only=terraform_deprecated_interpolation' 68 | - '--args=--only=terraform_deprecated_index' 69 | - '--args=--only=terraform_unused_declarations' 70 | - '--args=--only=terraform_comment_syntax' 71 | - '--args=--only=terraform_documented_outputs' 72 | - '--args=--only=terraform_documented_variables' 73 | - '--args=--only=terraform_typed_variables' 74 | - '--args=--only=terraform_module_pinned_source' 75 | - '--args=--only=terraform_naming_convention' 76 | - '--args=--only=terraform_required_version' 77 | - '--args=--only=terraform_required_providers' 78 | - '--args=--only=terraform_standard_module_structure' 79 | - '--args=--only=terraform_workspace_remote' 80 | 81 | - repo: git://github.com/pre-commit/pre-commit-hooks 82 | rev: v3.4.0 83 | hooks: 84 | - id: check-merge-conflict 85 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "Tari Kitchen Django website running on AWS" 5 | authors = ["Guillaume Bournique "] 6 | repository = "https://github.com/gbourniq/django-on-aws" 7 | readme = "README.md" 8 | keywords = ["portfolio"] 9 | 10 | [tool.poetry.dependencies] 11 | awscli = "^1.18.197" # Apache 2.0 12 | boto3 = "^1.12.39" # Apache 2.0 13 | django = "^3" # BSD 14 | django-debug-toolbar = "^3.2" # BSD 15 | django-filter = "^2.3.0" # MIT 16 | django-materializecss-form = "1.1.10" # MIT 17 | djangorestframework = "^3.11.0" # MIT 18 | django-redis = "^4.12.1" # BSD 19 | django-storages = "^1.9.1" # BSD 3 20 | django-tinymce4-lite = "1.7.5" # MIT 21 | gunicorn = "^19.9" # MIT 22 | pillow = "^7.0.0" # HPND 23 | python = "^3.8.0" # PSF 24 | requests = "^2.22" # Apache 2.0 25 | starlette = "^0.14.1" # BSD 3 26 | setuptools = "57.5.0" 27 | psycopg2-binary = "^2.9.3" 28 | 29 | [tool.poetry.dev-dependencies] 30 | ansible = "2.10.6" # GNU 31 | autoflake = "^1.4" # MIT 32 | black = "19.10b0" # MIT 33 | cfn-lint = "^0.56.3" # MIT 34 | chevron = "^0.13.1" # MIT 35 | coverage-badge = "^1.0.1" # MIT 36 | isort = "4.3.4" # MIT 37 | locust = "^1.4.3" # MIT 38 | pylint = "^2.6.0" # GPL 39 | pytest = "6.0.0" # MIT 40 | pytest-cov = "^2.10.1" # MIT 41 | pytest-django = "^3.9.0" # MIT 42 | pytest-env = "^0.6.2" # MIT 43 | pre-commit = "^2.8.2" # MIT 44 | wrapt = "^1.12.1" # MIT 45 | pylint-django = "^2.3.0" # GPLv2 46 | networkx = "^2.5" # BSD-new 47 | yamllint = "^1.25.0" # GNU 48 | 49 | [tool.pytest.ini_options] 50 | testpaths = "tests" 51 | filterwarnings = ''' 52 | error 53 | ignore::UserWarning 54 | ignore::django.utils.deprecation.RemovedInDjango40Warning 55 | ''' 56 | markers = ''' 57 | integration 58 | ''' 59 | python_files = ["tests.py", "test_*", "*_tests.py"] 60 | # Ignore python packages in sam-application/bin/ 61 | addopts = ''' 62 | --strict 63 | --tb=short 64 | --cov=. 65 | --cov-branch 66 | --cov-report=term-missing 67 | --cov-report=html 68 | --no-cov-on-fail 69 | --cov-fail-under=95 70 | ''' 71 | env = ["DJANGO_SETTINGS_MODULE=portfolio.settings", "CACHE_TTL=0"] 72 | 73 | [build-system] 74 | requires = ["wheel", "tomlkit", "poetry>=1.1.3"] # PEP 518 75 | build-backend = "poetry.masonry.api" -------------------------------------------------------------------------------- /app/portfolio/urls.py: -------------------------------------------------------------------------------- 1 | """portfolio URL Configuration""" 2 | 3 | import debug_toolbar 4 | from django.conf import settings 5 | from django.conf.urls.i18n import i18n_patterns 6 | from django.conf.urls.static import static 7 | from django.contrib import admin 8 | from django.core.cache.backends.base import DEFAULT_TIMEOUT 9 | from django.urls import include, path, re_path 10 | from django.views.decorators.cache import cache_page 11 | 12 | # from main.api_views import ( 13 | # CategoryCreate, 14 | # CategoryList, 15 | # CategoryRetrieveUpdateDestroyAPIView, 16 | # CategoryStats, 17 | # ItemCreate, 18 | # ItemList, 19 | # ItemRetrieveUpdateDestroyAPIView, 20 | # ) 21 | from main.errors import url_error 22 | from main.views import ( 23 | CategoriesView, 24 | ContactUsFormView, 25 | IndexView, 26 | ItemsView, 27 | LoginFormView, 28 | RedirectToItemView, 29 | SignUpFormView, 30 | logout_request, 31 | ) 32 | 33 | app_name = "main" # here for namespacing of urls. 34 | CACHE_TTL = getattr(settings, "CACHE_TTL", DEFAULT_TIMEOUT) 35 | CAT_PREFIX = "api/v1/categories" 36 | ITEMS_PREFIX = "api/v1/items" 37 | 38 | # For admin page automated translation from set locale region 39 | urlpatterns = i18n_patterns( 40 | path("admin/", admin.site.urls), 41 | # If no prefix is given, use the default language 42 | prefix_default_language=False, 43 | ) 44 | 45 | urlpatterns += [ 46 | # Django rest framework 47 | # path(f"{CAT_PREFIX}/", CategoryList.as_view()), 48 | # path(f"{CAT_PREFIX}/new", CategoryCreate.as_view()), 49 | # path(f"{CAT_PREFIX}//", CategoryRetrieveUpdateDestroyAPIView.as_view(),), 50 | # path(f"{CAT_PREFIX}//stats/", CategoryStats.as_view(),), 51 | # path(f"{ITEMS_PREFIX}/", ItemList.as_view()), 52 | # path(f"{ITEMS_PREFIX}/new", ItemCreate.as_view()), 53 | # path(f"{ITEMS_PREFIX}//", ItemRetrieveUpdateDestroyAPIView.as_view()), 54 | # User management 55 | path("__debug__/", include(debug_toolbar.urls)), 56 | path("register/", SignUpFormView.as_view(), name="register"), 57 | path("login/", LoginFormView.as_view(), name="login"), 58 | path("logout/", logout_request, name="logout"), 59 | # Views 60 | path("", IndexView.as_view(), name="home"), 61 | path( 62 | "contact/", 63 | (cache_page(CACHE_TTL))(ContactUsFormView.as_view()), 64 | name="contact_us", 65 | ), 66 | path( 67 | "items/", 68 | (cache_page(CACHE_TTL))(CategoriesView.as_view()), 69 | name="categories_view", 70 | ), 71 | path( 72 | "items///", 73 | (cache_page(CACHE_TTL))(ItemsView.as_view()), 74 | name="item_view", 75 | ), 76 | path( 77 | "items//", 78 | (cache_page(CACHE_TTL))(RedirectToItemView.as_view()), 79 | name="items_view", 80 | ), 81 | # Extra apps 82 | path("tinymce/", include("tinymce.urls")), 83 | # No url matched 84 | re_path(r"^.*/$", url_error, name="error404"), 85 | ] 86 | 87 | urlpatterns = ( 88 | static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 89 | + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 90 | + urlpatterns 91 | ) 92 | 93 | # Custom views for errors 94 | handler404 = "main.errors.handler404" 95 | 96 | # Customise admin page titles 97 | admin.site.index_title = "" 98 | admin.site.site_header = "Tari Kitchen Admin" 99 | -------------------------------------------------------------------------------- /app/main/item_content_template.html: -------------------------------------------------------------------------------- 1 |

 ✅ Ingrédients

2 |
    3 |
  1. Ingredient1
  2. 4 |
  3. Ingredient2
  4. 5 |
6 |

 

7 |

🔪 Préparation

8 |
    9 |
  1. Instruction1
  2. 10 |
  3. Instruction2
  4. 11 |
12 |

 

13 |

🕗 Cuisson

14 |
    15 |
  1. Instruction1
  2. 16 |
  3. Instruction2
  4. 17 |
18 |

 

19 |

 

20 |

Bon appétit! 👩🏻‍🍳

-------------------------------------------------------------------------------- /app/tests/test_views/test_login.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the login page""" 2 | 3 | from http import HTTPStatus 4 | from typing import Dict 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | from django.contrib.auth.forms import AuthenticationForm 9 | from django.contrib.auth.models import User 10 | from django.urls import reverse 11 | 12 | from helpers.constants import TemplateNames 13 | 14 | 15 | @pytest.mark.django_db(transaction=True) 16 | class TestViewLogin: 17 | """Tests for the login page""" 18 | 19 | @pytest.mark.integration 20 | # pylint: disable=no-self-use 21 | def test_view_login_page(self, client): 22 | """Test the Login page is rendered with the AuthenticationForm form""" 23 | # When: GET request on the login page 24 | response = client.get(reverse("login")) 25 | # Then: Login page is rendered with 200 and it includes the AuthenticationForm 26 | assert TemplateNames.LOGIN.value in [t.name for t in response.templates] 27 | assert response.status_code == HTTPStatus.OK.value 28 | assert isinstance(response.context["form"], AuthenticationForm) 29 | 30 | @pytest.mark.integration 31 | # pylint: disable=no-self-use 32 | def test_login_invalid_user( 33 | self, monkeypatch, client, mock_user: User, mock_invalid_user_dict: Dict 34 | ): 35 | """Test for when user enters invalid credentials on the login page""" 36 | 37 | # Given a mock authenticate function that returns a valid user 38 | monkeypatch.setattr( 39 | "django.contrib.auth.authenticate", 40 | mock_authenticate := Mock(return_value=mock_user), 41 | ) 42 | monkeypatch.setattr("main.views.login", mock_login := Mock()) 43 | 44 | # When: users attempts to login with invalid credentials 45 | response = client.post(reverse("login"), data=mock_invalid_user_dict) 46 | 47 | # Then: authenticate and login functions are not called 48 | mock_authenticate.assert_not_called() 49 | mock_login.assert_not_called() 50 | # Then: User stays on the login page with a 200 returned 51 | assert TemplateNames.LOGIN.value in [t.name for t in response.templates] 52 | assert response.status_code == HTTPStatus.OK.value 53 | 54 | @pytest.mark.integration 55 | # pylint: disable=no-self-use 56 | def test_login_valid_user( 57 | self, monkeypatch, client, mock_user: User, mock_user_dict: Dict 58 | ): 59 | """Test for when user enters valid credentials on the login page""" 60 | # Given a mock authenticate function that returns a valid user 61 | monkeypatch.setattr( 62 | "main.views.authenticate", mock_authenticate := Mock(return_value=mock_user) 63 | ) 64 | monkeypatch.setattr("main.views.login", mock_login := Mock()) 65 | 66 | # When: users attempts to login with valid credentials 67 | response = client.post(reverse("login"), data=mock_user_dict) 68 | 69 | # Then: authenticate and login functions are called 70 | mock_authenticate.assert_called_with( 71 | username=mock_user_dict.get("username"), 72 | password=mock_user_dict.get("password"), 73 | ) 74 | mock_login.assert_called_with(response.wsgi_request, mock_user) 75 | # Then: No templates are returned and user is redirect to the home page 76 | assert len(response.templates) == 0 77 | assert response.status_code == HTTPStatus.FOUND.value 78 | -------------------------------------------------------------------------------- /app/tests/test_models/test_item.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the Category django model""" 2 | 3 | from typing import List 4 | 5 | import pytest 6 | 7 | from app.tests.mocks import MockItem 8 | from main.models import Category, Item 9 | 10 | 11 | @pytest.mark.django_db(transaction=True) 12 | class TestItems: 13 | """Tests for the Item django model""" 14 | 15 | # pylint: disable=no-self-use 16 | def test_create_item( 17 | self, mock_default_category: Category, mock_default_item: Item 18 | ): 19 | """Tests item created with the expected attributes""" 20 | # Given: Default field values defined in mock.py 21 | # When: the mock_default_item fixture is called 22 | _id = f"{mock_default_category.id}-{MockItem.DEFAULT_ID}" 23 | 24 | attr_mapping = { 25 | mock_default_item.item_name: f"{MockItem.DEFAULT_ITEM_NAME}{_id}", 26 | mock_default_item.summary: f"{MockItem.DEFAULT_SUMMARY}{_id}", 27 | mock_default_item.content: f"{MockItem.DEFAULT_CONTENT}{_id}", 28 | mock_default_item.date_published: f"{MockItem.DEFAULT_DATE}", 29 | mock_default_item.category_name: mock_default_category, 30 | } 31 | # Then: Item object fields have been assigned correctly 32 | assert all( 33 | cat_attr == dummy_var for cat_attr, dummy_var in attr_mapping.items() 34 | ) 35 | 36 | # pylint: disable=no-self-use 37 | def test_attr_types(self, mock_default_item: Item): 38 | """Tests item created with the expected attributes types""" 39 | # Given: Default field values defined in mock.py 40 | # When: the mock_default_item fixture is called 41 | type_mapping = { 42 | mock_default_item.item_name: str, 43 | mock_default_item.summary: str, 44 | mock_default_item.content: str, 45 | mock_default_item.date_published: str, 46 | mock_default_item.item_slug: str, 47 | mock_default_item.category_name: Category, 48 | mock_default_item.views: int, 49 | } 50 | # Then: Item object fields have the correct types 51 | assert all( 52 | isinstance(attr, attr_type) for attr, attr_type in type_mapping.items() 53 | ) 54 | 55 | # pylint: disable=no-self-use 56 | def test_item_str_cast(self, mock_default_item: Item): 57 | """Tests Item str() method is overridden""" 58 | assert str(mock_default_item) == mock_default_item.item_name 59 | 60 | # pylint: disable=no-self-use 61 | def test_item_repr_cast(self, mock_default_item: Item): 62 | """Tests Item repr() method is overridden""" 63 | assert repr(mock_default_item) == ( 64 | f"Item=(id={mock_default_item.id},item_name=" 65 | f"{mock_default_item.item_name}," 66 | f"item_slug={mock_default_item.item_slug})" 67 | ) 68 | 69 | # pylint: disable=no-self-use 70 | def test_load_items(self, load_default_items: List[Item]): 71 | """Test that load_default_items does insert Item objects in the DB""" 72 | assert Item.objects.all().count() == len(load_default_items) 73 | 74 | # pylint: disable=no-self-use 75 | def test_mock_items(self, mock_default_items: List[Item]): 76 | """ 77 | Test that mock_default_items fixture does return Item objects 78 | but will not save them into the DB 79 | """ 80 | assert Item.objects.all().count() == 0 81 | assert all(isinstance(obj, Item) for obj in mock_default_items) 82 | -------------------------------------------------------------------------------- /deployment/dev/terraform/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "2.70.0" 6 | constraints = "~> 2.0" 7 | hashes = [ 8 | "h1:6tf4jg37RrMHyVCql+fEgAFvX8JiqDognr+lk6rx7To=", 9 | "h1:mM6eIaG1Gcrk47TveViXBO9YjY6nDaGukbED2bdo8Mk=", 10 | "zh:01a5f351146434b418f9ff8d8cc956ddc801110f1cc8b139e01be2ff8c544605", 11 | "zh:1ec08abbaf09e3e0547511d48f77a1e2c89face2d55886b23f643011c76cb247", 12 | "zh:606d134fef7c1357c9d155aadbee6826bc22bc0115b6291d483bc1444291c3e1", 13 | "zh:67e31a71a5ecbbc96a1a6708c9cc300bbfe921c322320cdbb95b9002026387e1", 14 | "zh:75aa59ae6f0834ed7142c81569182a658e4c22724a34db5d10f7545857d8db0c", 15 | "zh:76880f29fca7a0a3ff1caef31d245af2fb12a40709d67262e099bc22d039a51d", 16 | "zh:aaeaf97ffc1f76714e68bc0242c7407484c783d604584c04ad0b267b6812b6dc", 17 | "zh:ae1f88d19cc85b2e9b6ef71994134d55ef7830fd02f1f3c58c0b3f2b90e8b337", 18 | "zh:b155bdda487461e7b3d6e3a8d5ce5c887a047e4d983512e81e2c8266009f2a1f", 19 | "zh:ba394a7c391a26c4a91da63ad680e83bde0bc1ecc0a0856e26e9d62a4e77c408", 20 | "zh:e243c9d91feb0979638f28eb26f89ebadc179c57a2bd299b5729fb52bd1902f2", 21 | "zh:f6c05e20d9a3fba76ca5f47206dde35e5b43b6821c6cbf57186164ce27ba9f15", 22 | ] 23 | } 24 | 25 | provider "registry.terraform.io/hashicorp/local" { 26 | version = "2.1.0" 27 | constraints = "~> 2.1" 28 | hashes = [ 29 | "h1:EYZdckuGU3n6APs97nS2LxZm3dDtGqyM4qaIvsmac8o=", 30 | "h1:KfieWtVyGWwplSoLIB5usKAUnrIkDQBkWaR5TI+4WYg=", 31 | "zh:0f1ec65101fa35050978d483d6e8916664b7556800348456ff3d09454ac1eae2", 32 | "zh:36e42ac19f5d68467aacf07e6adcf83c7486f2e5b5f4339e9671f68525fc87ab", 33 | "zh:6db9db2a1819e77b1642ec3b5e95042b202aee8151a0256d289f2e141bf3ceb3", 34 | "zh:719dfd97bb9ddce99f7d741260b8ece2682b363735c764cac83303f02386075a", 35 | "zh:7598bb86e0378fd97eaa04638c1a4c75f960f62f69d3662e6d80ffa5a89847fe", 36 | "zh:ad0a188b52517fec9eca393f1e2c9daea362b33ae2eb38a857b6b09949a727c1", 37 | "zh:c46846c8df66a13fee6eff7dc5d528a7f868ae0dcf92d79deaac73cc297ed20c", 38 | "zh:dc1a20a2eec12095d04bf6da5321f535351a594a636912361db20eb2a707ccc4", 39 | "zh:e57ab4771a9d999401f6badd8b018558357d3cbdf3d33cc0c4f83e818ca8e94b", 40 | "zh:ebdcde208072b4b0f8d305ebf2bfdc62c926e0717599dcf8ec2fd8c5845031c3", 41 | "zh:ef34c52b68933bedd0868a13ccfd59ff1c820f299760b3c02e008dc95e2ece91", 42 | ] 43 | } 44 | 45 | provider "registry.terraform.io/hashicorp/null" { 46 | version = "3.1.0" 47 | constraints = "~> 3.1" 48 | hashes = [ 49 | "h1:vpC6bgUQoJ0znqIKVFevOdq+YQw42bRq0u+H3nto8nA=", 50 | "h1:xhbHC6in3nQryvTQBWKxebi3inG5OCgHgc4fRxL0ymc=", 51 | "zh:02a1675fd8de126a00460942aaae242e65ca3380b5bb192e8773ef3da9073fd2", 52 | "zh:53e30545ff8926a8e30ad30648991ca8b93b6fa496272cd23b26763c8ee84515", 53 | "zh:5f9200bf708913621d0f6514179d89700e9aa3097c77dac730e8ba6e5901d521", 54 | "zh:9ebf4d9704faba06b3ec7242c773c0fbfe12d62db7d00356d4f55385fc69bfb2", 55 | "zh:a6576c81adc70326e4e1c999c04ad9ca37113a6e925aefab4765e5a5198efa7e", 56 | "zh:a8a42d13346347aff6c63a37cda9b2c6aa5cc384a55b2fe6d6adfa390e609c53", 57 | "zh:c797744d08a5307d50210e0454f91ca4d1c7621c68740441cf4579390452321d", 58 | "zh:cecb6a304046df34c11229f20a80b24b1603960b794d68361a67c5efe58e62b8", 59 | "zh:e1371aa1e502000d9974cfaff5be4cfa02f47b17400005a16f14d2ef30dc2a70", 60 | "zh:fc39cc1fe71234a0b0369d5c5c7f876c71b956d23d7d6f518289737a001ba69b", 61 | "zh:fea4227271ebf7d9e2b61b89ce2328c7262acd9fd190e1fd6d15a591abfa848e", 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | parameters: 4 | ci_script: 5 | type: string 6 | default: "./build_steps/ci.sh" 7 | cd_script: 8 | type: string 9 | default: "./build_steps/cd.sh" 10 | 11 | workflows: 12 | ci_pipeline_only: 13 | jobs: 14 | - ci-pipeline 15 | - cd-pipeline-app-deployment: 16 | filters: 17 | branches: 18 | only: 19 | - main 20 | requires: 21 | - ci-pipeline 22 | scheduled_ci_cd_pipeline: 23 | triggers: 24 | - schedule: 25 | # Runs At 20:00 on day-of-month 1 26 | cron: "0 20 1 * *" 27 | filters: 28 | branches: 29 | only: 30 | - main 31 | jobs: 32 | - ci-pipeline 33 | - cd-pipeline-app-deployment: 34 | requires: 35 | - ci-pipeline 36 | - cd-pipeline-infra: 37 | requires: 38 | - cd-pipeline-app-deployment 39 | 40 | jobs: 41 | ci-pipeline: 42 | machine: 43 | image: ubuntu-2004:202010-01 44 | steps: 45 | - checkout 46 | - run: 47 | name: "Build CI/CD and webapp docker image" 48 | command: << pipeline.parameters.ci_script >> build 49 | - run: 50 | name: "Run postgres and redis databases" 51 | command: << pipeline.parameters.ci_script >> start_db 52 | - run: 53 | name: "Run unit tests" 54 | command: << pipeline.parameters.ci_script >> unit_tests 55 | - run: 56 | name: "Run pre commit hooks (linting)" 57 | command: << pipeline.parameters.ci_script >> lint 58 | - run: 59 | name: "Run webapp container and check health" 60 | command: << pipeline.parameters.ci_script >> healthcheck 61 | - run: 62 | name: "Stop and remove all containers" 63 | command: << pipeline.parameters.ci_script >> clean 64 | - run: 65 | name: "Publish images to Dockerhub" 66 | command: << pipeline.parameters.ci_script >> push_images 67 | cd-pipeline-app-deployment: 68 | machine: 69 | image: ubuntu-2004:202010-01 70 | steps: 71 | - checkout 72 | - run: 73 | name: "Push variables to ssm paramter store for CD pipeline" 74 | command: << pipeline.parameters.ci_script >> put_ssm_vars 75 | - run: 76 | name: "Deploy newest application image on the live stack ec2 instances" 77 | command: CFN_STACK_NAME=live << pipeline.parameters.cd_script >> code_deploy 78 | cd-pipeline-infra: 79 | machine: 80 | image: ubuntu-2004:202010-01 81 | steps: 82 | - checkout 83 | - run: 84 | name: Create AWS infrastructure 85 | no_output_timeout: 30m 86 | command: CFN_STACK_NAME=demo R53_SUB_DOMAIN=True << pipeline.parameters.cd_script >> cfn_create 87 | - run: 88 | name: Deploy application to AWS 89 | command: CFN_STACK_NAME=demo << pipeline.parameters.cd_script >> code_deploy 90 | - run: 91 | name: Load Testing application 92 | command: CFN_STACK_NAME=demo R53_SUB_DOMAIN=True << pipeline.parameters.cd_script >> load_testing 93 | - run: 94 | name: Delete AWS infrastructure 95 | command: CFN_STACK_NAME=demo << pipeline.parameters.cd_script >> cfn_destroy_async 96 | - run: 97 | name: CD build has failed! Cleaning up any created AWS resources... 98 | command: CFN_STACK_NAME=demo << pipeline.parameters.cd_script >> cfn_destroy_async || true 99 | when: on_fail 100 | -------------------------------------------------------------------------------- /app/main/serializers.py: -------------------------------------------------------------------------------- 1 | # """This module defines serialiazers for the django rest framework api views""" 2 | 3 | # from django.utils import timezone 4 | # from rest_framework import serializers 5 | 6 | # from main.models import Category, Item 7 | 8 | 9 | # class CategorySerializer(serializers.ModelSerializer): 10 | # """ 11 | # Class to serialize Category model. 12 | # Adding child_items SerializeMethod to display 13 | # Category child elements (Items) 14 | # """ 15 | 16 | # category_name = serializers.CharField( 17 | # min_length=0, 18 | # max_length=20, 19 | # style={"input_type": "text", "placeholder": "My Category Name"}, 20 | # ) 21 | # summary = serializers.CharField( 22 | # min_length=2, 23 | # max_length=200, 24 | # style={"input_type": "text", "placeholder": "My category summary"}, 25 | # ) 26 | # image = serializers.ImageField(required=False) 27 | # category_slug = serializers.CharField( 28 | # style={"input_type": "text", "placeholder": "url-slug-to-category"}, 29 | # help_text="URL slug to redirect to your category. No space allowed!", 30 | # ) 31 | # child_items = serializers.SerializerMethodField() 32 | 33 | # class Meta: 34 | # model = Category 35 | # fields = ( 36 | # "id", 37 | # "category_name", 38 | # "summary", 39 | # "image", 40 | # "category_slug", 41 | # "child_items", 42 | # ) 43 | 44 | # def get_child_items(self, instance): 45 | # """ 46 | # SerializerMethod to return a list of serialized model instances 47 | # (many=True) 48 | # """ 49 | # items = Item.objects.filter(category_name=instance) 50 | # return ItemSerializer(items, many=True).data 51 | 52 | # # def to_representation(self, instance): 53 | # # return super().to_representation(instance) 54 | 55 | 56 | # class ItemSerializer(serializers.ModelSerializer): 57 | # """Class to serialize Item model""" 58 | 59 | # item_name = serializers.CharField( 60 | # min_length=0, 61 | # max_length=20, 62 | # style={"input_type": "text", "placeholder": "My Item Name"}, 63 | # ) 64 | # summary = serializers.CharField( 65 | # min_length=2, 66 | # max_length=200, 67 | # style={"input_type": "text", "placeholder": "My item summary"}, 68 | # ) 69 | # content = serializers.CharField( 70 | # min_length=10, 71 | # style={"input_type": "text", "placeholder": "Write item content here...",}, 72 | # help_text="Use the django admin page to insert formatted text", 73 | # ) 74 | # date_published = serializers.DateTimeField( 75 | # default=timezone.now, 76 | # help_text="Leave blank for current datetime", 77 | # ) 78 | # item_slug = serializers.CharField( 79 | # style={"input_type": "text", "placeholder": "url-slug-to-item"}, 80 | # help_text="URL slug to redirect to your item. No space allowed!", 81 | # ) 82 | # views = serializers.IntegerField(read_only=True) 83 | 84 | # class Meta: 85 | # model = Item 86 | # fields = ( 87 | # "id", 88 | # "item_name", 89 | # "summary", 90 | # "content", 91 | # "date_published", 92 | # "item_slug", 93 | # "category_name", 94 | # "views", 95 | # ) 96 | 97 | 98 | # # pylint: disable=abstract-method 99 | # class StatSerializer(serializers.Serializer): 100 | # """Class to serialize CategoryStats""" 101 | 102 | # stats = serializers.DictField(child=serializers.IntegerField()) 103 | -------------------------------------------------------------------------------- /deployment/prod/cloudformation/database/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Metadata: 3 | License: Apache-2.0 4 | Description: | 5 | This CloudFormation template defines the databases backend for the stack. 6 | - RDS Postgres with Read Replica in another AZ 7 | - ElastiCache Redis cluster 8 | 9 | Parameters: 10 | AddRDSReadReplica: 11 | Description: Boolean to add an RDS read replica. Defaults to false 12 | Type: String 13 | AllowedValues: ['true', 'false'] 14 | Default: 'false' 15 | ElasticacheSecurityGroupId: 16 | Description: Security Group Id for ElastiCache to allow incoming request from EC2s 17 | Type: String 18 | RDSSecurityGroupName: 19 | Description: Security Group Name for RDS to allow incoming request from EC2s 20 | Type: String 21 | SSMParamNameRdsPostgresPassword: 22 | NoEcho: true 23 | Description: SSM Parameter Name for the RDS password SecureString. 24 | Type: String 25 | 26 | Conditions: 27 | RDSReadReplica: !And [!Equals [ !Ref AddRDSReadReplica, 'true' ], !Not [!Equals [ !Ref AWS::StackName, demo]]] 28 | DevEnvironment: !Equals [ !Ref AWS::StackName, demo] 29 | ProdEnvironment: !Not [!Equals [ !Ref AWS::StackName, demo]] 30 | 31 | Resources: 32 | DevRDSPostgresDB: 33 | Condition: DevEnvironment 34 | Type: AWS::RDS::DBInstance 35 | DeletionPolicy: Delete 36 | UpdateReplacePolicy: Retain 37 | Properties: 38 | BackupRetentionPeriod: 0 # disables automated backups 39 | DeletionProtection: false 40 | Engine: postgres 41 | EngineVersion: '12.7' 42 | DBInstanceClass: db.t2.micro 43 | AllocatedStorage: '5' 44 | DBName: portfoliodb 45 | MasterUsername: postgres 46 | MasterUserPassword: 47 | !Sub 48 | - "{{resolve:ssm-secure:${paramName}:1}}" 49 | - { paramName: !Ref SSMParamNameRdsPostgresPassword } 50 | Port: '5432' 51 | MultiAZ: false 52 | PubliclyAccessible: true 53 | StorageType: gp2 54 | StorageEncrypted: false 55 | VPCSecurityGroups: [!Ref RDSSecurityGroupName] 56 | ProdRDSPostgresDB: 57 | Condition: ProdEnvironment 58 | Type: AWS::RDS::DBInstance 59 | DeletionPolicy: Snapshot 60 | UpdateReplacePolicy: Retain 61 | Properties: 62 | BackupRetentionPeriod: 0 # The number of days for which automated backups are retained 63 | DeletionProtection: false 64 | Engine: postgres 65 | EngineVersion: '12.7' 66 | DBInstanceClass: db.t2.micro 67 | AllocatedStorage: '5' 68 | DBName: portfoliodb 69 | MasterUsername: postgres 70 | MasterUserPassword: 71 | !Sub 72 | - "{{resolve:ssm-secure:${paramName}:1}}" 73 | - { paramName: !Ref SSMParamNameRdsPostgresPassword } 74 | Port: '5432' 75 | MultiAZ: false 76 | PubliclyAccessible: true 77 | StorageType: gp2 78 | StorageEncrypted: false 79 | VPCSecurityGroups: [!Ref RDSSecurityGroupName] 80 | ReadReplicaDB: 81 | Condition: RDSReadReplica 82 | Type: AWS::RDS::DBInstance 83 | Properties: 84 | SourceDBInstanceIdentifier: !Ref ProdRDSPostgresDB 85 | DBInstanceClass: db.t2.micro 86 | Tags: 87 | - Key: Description 88 | Value: Read Replica Database 89 | ElasticacheRedisCluster: 90 | Type: AWS::ElastiCache::CacheCluster 91 | DeletionPolicy: Delete 92 | Properties: 93 | AZMode: single-az 94 | AutoMinorVersionUpgrade: true 95 | Engine: redis 96 | CacheNodeType: cache.t2.micro 97 | NumCacheNodes: 1 98 | VpcSecurityGroupIds: [!Ref ElasticacheSecurityGroupId] 99 | 100 | Outputs: 101 | RedisEndpoint: 102 | Value: !GetAtt ElasticacheRedisCluster.RedisEndpoint.Address 103 | PostgresEndpoint: 104 | Value: !If [DevEnvironment, !GetAtt DevRDSPostgresDB.Endpoint.Address, !GetAtt ProdRDSPostgresDB.Endpoint.Address] 105 | -------------------------------------------------------------------------------- /app/tests/test_views/test_register.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the register page""" 2 | 3 | from http import HTTPStatus 4 | from typing import Dict 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | from django.contrib.auth.models import User 9 | from django.urls import reverse 10 | 11 | from helpers.constants import TemplateNames 12 | from main.forms import NewUserForm 13 | 14 | 15 | @pytest.mark.django_db(transaction=True) 16 | class TestViewRegister: 17 | """Tests for the register page""" 18 | 19 | @pytest.mark.integration 20 | # pylint: disable=no-self-use 21 | def test_get_register_page(self, client): 22 | """Test for when a user attempts to register""" 23 | # When: GET request on the register page 24 | response = client.get(reverse("register")) 25 | # Then: the register template is rendered with the NewUserForm form 26 | assert TemplateNames.REGISTER.value in [t.name for t in response.templates] 27 | assert response.status_code == HTTPStatus.OK.value 28 | assert isinstance(response.context["form"], NewUserForm) 29 | 30 | @pytest.mark.integration 31 | # pylint: disable=no-self-use 32 | def test_register_with_valid_form( 33 | self, monkeypatch, client, mock_user_form_dict: Dict 34 | ): 35 | """Tests for when user register successfully""" 36 | # Given: a mock login function 37 | monkeypatch.setattr("main.views.login", mock_login := Mock()) 38 | # When: user register with valid data 39 | response = client.post(reverse("register"), data=mock_user_form_dict) 40 | # Then: the login function is called 41 | mock_login.assert_called() 42 | assert len(response.templates) == 0 43 | assert response.status_code == HTTPStatus.FOUND.value 44 | 45 | @pytest.mark.integration 46 | # pylint: disable=no-self-use 47 | def test_register_with_invalid_form(self, monkeypatch, client, mock_user: User): 48 | """Tests for when user attempts to register with invalid form data""" 49 | # Given: invalid register form data 50 | mock_user_form_invalid_data = { 51 | "username": "", 52 | "email": "invalid_email", 53 | "password1": "invalidpass", 54 | "password2": "invalidpass_not_matching", 55 | } 56 | # Given: mock login function and some user is registered 57 | monkeypatch.setattr("main.views.login", mock_login := Mock()) 58 | monkeypatch.setattr( 59 | NewUserForm, "save", mock_save_form := Mock(return_value=mock_user) 60 | ) 61 | # When: POST the invalid form data on the register page 62 | response = client.post(reverse("register"), data=mock_user_form_invalid_data) 63 | # Then: User is not saved through the form, and login function not called 64 | mock_save_form.assert_not_called() 65 | mock_login.assert_not_called() 66 | # Then: User stays on the register page 67 | assert TemplateNames.REGISTER.value in [t.name for t in response.templates] 68 | assert response.status_code == HTTPStatus.OK.value 69 | 70 | @pytest.mark.integration 71 | # pylint: disable=no-self-use 72 | def test_register_with_existing_user_conflict( 73 | self, monkeypatch, client, mock_user: User, mock_user_dict: Dict 74 | ): 75 | """Tests for when registering with existing user details""" 76 | # Given: Form data where username conflicts with existing mock_user 77 | # Given: mock login function and some user is registered 78 | monkeypatch.setattr("main.views.login", mock_login := Mock()) 79 | monkeypatch.setattr( 80 | NewUserForm, "save", mock_save_form := Mock(return_value=mock_user) 81 | ) 82 | # When: POST user form data of a user that already exists 83 | response = client.post(reverse("register"), data=mock_user_dict) 84 | # Then: User is not saved through the form, and login function not called 85 | mock_save_form.assert_not_called() 86 | mock_login.assert_not_called() 87 | assert TemplateNames.REGISTER.value in [t.name for t in response.templates] 88 | assert response.status_code == HTTPStatus.OK.value 89 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Oneshell means I can run multiple lines in a recipe in the same shell, so I don't have to 2 | # chain commands together with semicolon 3 | .ONESHELL: 4 | 5 | # Set shell 6 | SHELL=/bin/bash 7 | 8 | # Conda environment 9 | CONDA_ENV_NAME=django-on-aws 10 | CONDA_CREATE=source $$(conda info --base)/etc/profile.d/conda.sh ; conda env create 11 | CONDA_ACTIVATE=source $$(conda info --base)/etc/profile.d/conda.sh ; conda activate 12 | 13 | include utils/helpers.mk 14 | 15 | ### Environment and githooks ### 16 | .PHONY: env env-update pre-commit 17 | 18 | env: 19 | @ ${INFO} "Creating ${CONDA_ENV_NAME} conda environment and poetry dependencies" 20 | @ $(CONDA_CREATE) -f environment.yml -n $(CONDA_ENV_NAME) 21 | @ ($(CONDA_ACTIVATE) $(CONDA_ENV_NAME); poetry install) 22 | @ ${SUCCESS} "${CONDA_ENV_NAME} conda environment has been created and dependencies installed with Poetry." 23 | @ ${MESSAGE} "Please activate the environment with: conda activate ${CONDA_ENV_NAME}" 24 | 25 | env-update: 26 | @ ${INFO} "Updating ${CONDA_ENV_NAME} conda environment and poetry dependencies" 27 | @ conda env update -f environment.yml -n $(CONDA_ENV_NAME) 28 | @ ($(CONDA_ACTIVATE) $(CONDA_ENV_NAME); poetry update) 29 | @ ${SUCCESS} "${CONDA_ENV_NAME} conda environment and poetry dependencies have been updated!" 30 | 31 | pre-commit: 32 | @ pre-commit install -t pre-commit -t commit-msg 33 | @ ${SUCCESS} "pre-commit set up" 34 | 35 | 36 | ### Development (CI) ### 37 | .PHONY: build start_db runserver up tests cov clean 38 | 39 | build: 40 | @ ${INFO} "Build CI and webapp images" 41 | @ ./build_steps/ci.sh build 42 | 43 | start_db: 44 | @ ${INFO} "Start databases containers" 45 | @ ./build_steps/ci.sh start_db 46 | 47 | runserver: 48 | POSTGRES_HOST=localhost REDIS_ENDPOINT=localhost:6379 python app/manage.py collectstatic --no-input -v 0 49 | POSTGRES_HOST=localhost REDIS_ENDPOINT=localhost:6379 python app/manage.py makemigrations main 50 | POSTGRES_HOST=localhost REDIS_ENDPOINT=localhost:6379 python app/manage.py migrate --run-syncdb 51 | POSTGRES_HOST=localhost REDIS_ENDPOINT=localhost:6379 DJANGO_SUPERUSER_PASSWORD=test python app/manage.py createsuperuser --username test --email gbournique@gmail.com --noinput || true 52 | POSTGRES_HOST=localhost REDIS_ENDPOINT=localhost:6379 python app/manage.py runserver 0.0.0.0:8080 53 | 54 | up: 55 | @ ${INFO} "Start databases and webapp containers" 56 | @ ./build_steps/ci.sh up 57 | 58 | tests: 59 | @ ${INFO} "Run unit tests inside container" 60 | @ ./build_steps/ci.sh unit_tests 61 | 62 | cov: 63 | @ ${INFO} "Open test coverage results" 64 | @ open htmlcov/index.html 65 | 66 | clean: 67 | @ ${INFO} "Stopping and removing all containers" 68 | @ ./build_steps/ci.sh clean 69 | 70 | 71 | ### Terraform deployment (CD) ### 72 | .PHONY: tf-deploy tf-destroy 73 | 74 | tf-deploy: 75 | @ ${INFO} "Deploying application to a new ec2 instance with Terraform+Ansible" 76 | @ ${INFO} "For a production level deployment, use the cfn scripts in ./cd.sh" 77 | @ ./build_steps/cd.sh tf_launch 78 | @ ./build_steps/cd.sh tf_provision 79 | 80 | tf-destroy: 81 | @ ./build_steps/cd.sh tf_destroy 82 | 83 | 84 | ### CI/CD scripts ### 85 | .PHONY: ci-help cd-help ci-all cd-all 86 | 87 | ci-help: 88 | @ ./build_steps/ci.sh || true 89 | 90 | cd-help: 91 | @ ./build_steps/cd.sh || true 92 | 93 | ci-all: 94 | @ ${INFO} "Running the CI pipeline build steps locally" 95 | ./build_steps/ci.sh build 96 | ./build_steps/ci.sh start_db 97 | ./build_steps/ci.sh unit_tests 98 | ./build_steps/ci.sh lint 99 | ./build_steps/ci.sh healthcheck 100 | ./build_steps/ci.sh clean 101 | ./build_steps/ci.sh push_images 102 | ./build_steps/ci.sh put_ssm_vars 103 | 104 | cd-all: 105 | @ ${INFO} "Running the CD pipeline build steps locally" 106 | CFN_STACK_NAME=demo R53_SUB_DOMAIN=True ./build_steps/cd.sh cfn_create 107 | CFN_STACK_NAME=demo ./build_steps/cd.sh code_deploy 108 | CFN_STACK_NAME=demo R53_SUB_DOMAIN=True ./build_steps/cd.sh cfn_update 109 | CFN_STACK_NAME=demo R53_SUB_DOMAIN=True ./build_steps/cd.sh load_testing 110 | CFN_STACK_NAME=demo ./build_steps/cd.sh cfn_destroy_async 111 | 112 | backup: 113 | CFN_STACK_NAME="live" R53_SUB_DOMAIN=False S3_DATA_BACKUP_URI="s3://gbournique-artefacts/tari.kitchen-backups" ./build_steps/cd.sh create_backup -------------------------------------------------------------------------------- /app/main/models.py: -------------------------------------------------------------------------------- 1 | """This module defines the Django models Item and Category to manage blog posts""" 2 | from pathlib import Path 3 | 4 | from django.conf import settings 5 | from django.db import models 6 | from django.utils import timezone 7 | from django.utils.text import slugify 8 | 9 | from app.helpers.constants import THUMBNAIL_SUFFIX 10 | 11 | from .mixins import BaseModelMixin 12 | 13 | HTML_TEMPLATE_PATH = Path(__file__).resolve().parent / "item_content_template.html" 14 | 15 | 16 | class Category(models.Model, BaseModelMixin): 17 | """Django model to manage blog post categories""" 18 | 19 | id = models.AutoField(primary_key=True) 20 | category_name = models.CharField( 21 | max_length=200, unique=True, verbose_name="Nom de la catégorie" 22 | ) 23 | summary = models.TextField() 24 | image = models.ImageField( 25 | upload_to=settings.UPLOADS_FOLDER_PATH, verbose_name="Photo" 26 | ) 27 | category_slug = models.SlugField(max_length=50, unique=True) 28 | 29 | @classmethod 30 | def create(cls, kwargs) -> "Category": 31 | """ 32 | Instantiate a Category objects using dictionaries. 33 | Usage: new_category = Category.create(datadict) 34 | """ 35 | return cls(**kwargs) 36 | 37 | # pylint: disable=signature-differs 38 | def save(self, *args, **kwargs): 39 | """Any modification on the item attributes before saving the object.""" 40 | self.image = self.resize_image(self.image) 41 | self.category_slug = slugify(self.category_name) 42 | super().save(*args, **kwargs) 43 | 44 | def __str__(self): 45 | """User-friendly string representation of the object""" 46 | return self.category_name 47 | 48 | def __repr__(self): 49 | """User-friendly string representation of the object""" 50 | return ( 51 | f"Category=(id={self.id},category_name={self.category_name}" 52 | f",category_slug={self.category_slug})" 53 | ) 54 | 55 | class Meta: 56 | verbose_name = "Catégorie" 57 | verbose_name_plural = "Catégories" 58 | app_label = "main" 59 | 60 | 61 | class Item(models.Model, BaseModelMixin): 62 | """Django model to manage blog post items""" 63 | 64 | id = models.AutoField(primary_key=True) 65 | item_name = models.CharField( 66 | max_length=200, unique=True, verbose_name="Nom de la recette" 67 | ) 68 | summary = models.CharField(max_length=200) 69 | image = models.ImageField( 70 | upload_to=settings.UPLOADS_FOLDER_PATH, verbose_name="Photo" 71 | ) 72 | # Resized image used for cards in email notifications 73 | image_thumbnail = models.ImageField( 74 | upload_to=settings.UPLOADS_FOLDER_PATH, default="" 75 | ) 76 | with open(HTML_TEMPLATE_PATH) as f: 77 | content = models.TextField(default=f.read(), verbose_name="Contenu") 78 | date_published = models.DateTimeField("date published", default=timezone.now) 79 | item_slug = models.SlugField(max_length=50, unique=True) 80 | category_name = models.ForeignKey( 81 | Category, default=1, verbose_name="Catégorie", on_delete=models.SET_DEFAULT, 82 | ) 83 | views = models.IntegerField(default=0) 84 | 85 | @classmethod 86 | def create(cls, kwargs: dict) -> "Item": 87 | """Instantiate Item objects using dictionaries. Used by tests""" 88 | return cls(**kwargs) 89 | 90 | # pylint: disable=signature-differs 91 | def save(self, *args, **kwargs): 92 | """Any modification on the item attributes before saving the object.""" 93 | self.image_thumbnail = self.resize_image(self.image, suffix=THUMBNAIL_SUFFIX) 94 | self.item_slug = slugify(self.item_name) 95 | super().save(*args, **kwargs) 96 | 97 | def increment_views(self): 98 | """Instance method to increment the views variable""" 99 | self.views += 1 100 | self.save() 101 | 102 | def __str__(self): 103 | """User-friendly string representation of the object""" 104 | return self.item_name 105 | 106 | def __repr__(self): 107 | """User-friendly string representation of the object""" 108 | return ( 109 | f"Item=(id={self.id},item_name={self.item_name},item_slug={self.item_slug})" 110 | ) 111 | 112 | class Meta: 113 | verbose_name = "Recettes" 114 | verbose_name_plural = "Recettes" 115 | app_label = "main" 116 | -------------------------------------------------------------------------------- /app/main/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines Admin models to map our Category and Item models so that 3 | they can be managed via the Django admin page /admin 4 | """ 5 | import json 6 | import logging 7 | from typing import NoReturn 8 | 9 | import boto3 10 | from django.contrib import admin 11 | from django.contrib.auth.models import User 12 | from django.db import models 13 | from django.forms import ModelForm 14 | from django.http import HttpRequest 15 | from tinymce.widgets import TinyMCE 16 | 17 | from app.config import AWS_REGION, AWS_S3_CUSTOM_DOMAIN, SES_IDENTITY_ARN 18 | 19 | from .models import Category, Item 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class ItemAdmin(admin.ModelAdmin): 25 | """ 26 | Class to add an Item from the Django admin page with the TinyMCE 27 | plugin which provides text formatting options 28 | """ 29 | 30 | fieldsets = [ 31 | ("1. Entrer le nom de la recette", {"fields": ["item_name"]}), 32 | ("2. Selectionner une catégorie", {"fields": ["category_name"]}), 33 | ("3. Ajouter une photo et les instructions", {"fields": ["image", "content"]}), 34 | ] 35 | # Add TinyMCE Widget to textfield property 36 | formfield_overrides = { 37 | models.TextField: {"widget": TinyMCE()}, 38 | } 39 | 40 | # To have the number of item views from the admin panel 41 | readonly_fields = ("views",) 42 | 43 | def save_model( 44 | self, request: HttpRequest, item: Item, form: ModelForm, change: bool 45 | ): 46 | super().save_model(request, item, form, change) 47 | self.notify_registered_users(item, is_modified=change) 48 | 49 | @staticmethod 50 | def notify_registered_users(item: Item, is_modified: bool) -> NoReturn: 51 | """ 52 | Notify users via AWS SES. 53 | Registered emails must be manually added as SES verified identifies from 54 | within the AWS Console, because of the limited / sandbox environment. 55 | For a production use case, raise a ticket with AWS. 56 | """ 57 | if is_modified: 58 | logger.info("Item {item} was modified, not created. Skipping SES email") 59 | return 60 | 61 | ses_client = boto3.client("ses", region_name=AWS_REGION) 62 | 63 | destinations = [ 64 | { 65 | "Destination": {"ToAddresses": [user.email],}, 66 | "ReplacementTemplateData": json.dumps( 67 | { 68 | "base_url": f"https://{AWS_S3_CUSTOM_DOMAIN}", 69 | "item_name": item.item_name, 70 | "item_page_url": f"https://{AWS_S3_CUSTOM_DOMAIN}/items/{item.category_name.category_slug}/{item.item_slug}", 71 | "item_image_url": item.image_thumbnail.url, 72 | "action": "modifiée" if is_modified else "ajoutée", 73 | "subject": "🍚 Tari-Recette mise à jour!" 74 | if is_modified 75 | else "🍚 Nouvelle Tari-Recette disponible!", 76 | "username": user.username.title(), 77 | "email": user.email, 78 | } 79 | ), 80 | } 81 | for user in User.objects.all() 82 | if user.username and user.email 83 | ] 84 | 85 | if not SES_IDENTITY_ARN: 86 | logger.info("SES_IDENTITY_ARN not set. Skipping SES email.") 87 | return 88 | 89 | try: 90 | response = ses_client.send_bulk_templated_email( 91 | Source="tari-alerts@tari.kitchen", 92 | SourceArn=SES_IDENTITY_ARN, 93 | ReplyToAddresses=[], 94 | DefaultTags=[], 95 | Template="ItemCreatedOrModifiedNotification", # TODO: remove hardcoded TemplateName. Could use TemplateArn from Cfn 96 | DefaultTemplateData=json.dumps( 97 | {"base_url": f"https://{AWS_S3_CUSTOM_DOMAIN}"} 98 | ), 99 | Destinations=destinations, 100 | ) 101 | logger.info(f"SES notification was successful. response: {response}") 102 | except Exception as ses_err: 103 | logger.error(f"Failed to send SES email notification: {repr(ses_err)}") 104 | 105 | 106 | class CategoryAdmin(admin.ModelAdmin): 107 | """Class to add a Category from the Django admin page.""" 108 | 109 | fields = ("category_name", "image") 110 | 111 | 112 | # Register models 113 | admin.site.register(Item, ItemAdmin) 114 | admin.site.register(Category, CategoryAdmin) 115 | -------------------------------------------------------------------------------- /app/tests/test_views/test_contactus.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the contact us page""" 2 | 3 | import json 4 | from http import HTTPStatus 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | from django.conf import settings 9 | from django.urls import reverse 10 | 11 | from helpers.constants import TemplateNames 12 | from main.forms import ContactForm 13 | 14 | 15 | @pytest.mark.django_db(transaction=True) 16 | class TestViewContactUs: 17 | """Tests for the contact us page""" 18 | 19 | @pytest.mark.integration 20 | @pytest.mark.parametrize("login_required", [True, False]) 21 | # pylint: disable=no-self-use 22 | def test_view_contact_us_page(self, client, monkeypatch, login_required: bool): 23 | """ 24 | Test the view Category us page is rendered with the Contact Form 25 | only if ENABLE_LOGIN_REQUIRED_MIXIN set to False, or user is logged in 26 | """ 27 | # Given: ENABLE_LOGIN_REQUIRED_MIXIN set to True/False 28 | monkeypatch.setattr( 29 | settings, "ENABLE_LOGIN_REQUIRED_MIXIN", login_required, 30 | ) 31 | # When: A GET request to the contact us page 32 | response = client.get(reverse("contact_us")) 33 | rendered_templates = [t.name for t in response.templates] 34 | if login_required: 35 | # Then: No templates rendered and returns 302 36 | assert not rendered_templates 37 | assert response.status_code == HTTPStatus.FOUND.value 38 | else: 39 | # Then: contact_us template is rendered with 200 40 | assert TemplateNames.CONTACT_US.value in rendered_templates 41 | assert response.status_code == HTTPStatus.OK.value 42 | assert isinstance(response.context["form"], ContactForm) 43 | 44 | @pytest.mark.integration 45 | @pytest.mark.parametrize("sns_topic_arn", ["mock_sns_topic_arn", ""]) 46 | # pylint: disable=no-self-use 47 | def test_post_valid_form( 48 | self, monkeypatch, client, mock_contact_form: ContactForm, sns_topic_arn: str 49 | ): 50 | """Test the `Go Back Home` page is rendered when user submit valid form""" 51 | # Given: Whether SNS_TOPIC_ARN is set or not 52 | monkeypatch.setattr( 53 | settings, "SNS_TOPIC_ARN", sns_topic_arn, 54 | ) 55 | # Given: mock sns service client 56 | monkeypatch.setattr("boto3.client", mock_sns_client := Mock()) 57 | # When: POST request on the contact-us page 58 | response = client.post(reverse("contact_us"), data=mock_contact_form.json()) 59 | if sns_topic_arn: 60 | # Given: SNS_TOPIC_ARN is set 61 | # Then: `go-back-home` template is rendered 62 | assert TemplateNames.GO_BACK_HOME.value in [ 63 | t.name for t in response.templates 64 | ] 65 | assert response.status_code == HTTPStatus.OK.value 66 | # Then: SNS client called with expected parameters 67 | mock_sns_client("sns").publish.assert_called_once_with( 68 | TargetArn="mock_sns_topic_arn", 69 | Message=json.dumps({"default": mock_contact_form.json()}), 70 | ) 71 | else: 72 | # Given: SNS_TOPIC_ARN is NOT set 73 | # Then: Email not sent and user stays on the same page 74 | assert TemplateNames.CONTACT_US.value in [ 75 | t.name for t in response.templates 76 | ] 77 | assert response.status_code == HTTPStatus.OK.value 78 | 79 | @pytest.mark.integration 80 | # pylint: disable=no-self-use 81 | def test_post_empty_form(self, client): 82 | """When a user submits an empty form, it will redirect to the current page""" 83 | # When: POST request on the contact us page, with an empty form 84 | contact_us_url = reverse("contact_us") 85 | response = client.post(contact_us_url, data={}, HTTP_REFERER=contact_us_url) 86 | # Then: the rendered template is the current `contact-us` template 87 | assert TemplateNames.CONTACT_US.value in [t.name for t in response.templates] 88 | assert response.status_code == HTTPStatus.OK.value 89 | 90 | @pytest.mark.integration 91 | @pytest.mark.parametrize( 92 | "name, contact_email, subject, message", 93 | [ 94 | ("", "valid@email.com", "valid subject", "valid message"), 95 | ("valid name", "invalid email", "valid subject", "valid message"), 96 | ("valid name", "", "valid subject", "valid message"), 97 | ("valid name", "valid@email.com", "", "valid message"), 98 | ("valid name", "valid@email.com", "valid subject", ""), 99 | ], 100 | ) 101 | # pylint: disable=no-self-use 102 | # pylint: disable=too-many-arguments 103 | def test_post_invalid_form( 104 | self, client, name: str, contact_email: str, subject: str, message: str, 105 | ): 106 | """Ensures page rediction when invalid form is submitted""" 107 | # Given: Form with invalid data 108 | invalid_form = ContactForm() 109 | invalid_form.name = name 110 | invalid_form.contact_email = contact_email 111 | invalid_form.subject = subject 112 | invalid_form.message = message 113 | # When: the form is posted on the contact-us page 114 | contact_us_url = reverse("contact_us") 115 | response = client.post( 116 | contact_us_url, data=invalid_form.json(), HTTP_REFERER=contact_us_url, 117 | ) 118 | # Then: user stays on the contact-us page 119 | assert TemplateNames.CONTACT_US.value in [t.name for t in response.templates] 120 | assert response.status_code == HTTPStatus.OK.value 121 | -------------------------------------------------------------------------------- /deployment/dev/README.md: -------------------------------------------------------------------------------- 1 | # Terraform+Ansible Documentation 2 | 3 | This README contains instructions on how to set up terraform and ansible to automatically create and configure ec2 instances. 4 | 5 | ### Pre-requisites 6 | 7 | Install the following software and packages on your local machine: 8 | - [terraform cli](https://learn.hashicorp.com/tutorials/terraform/install-cli) 9 | - [terraform-docs](https://github.com/terraform-docs/terraform-docs) 10 | - [tflint](https://github.com/terraform-linters/tflint) 11 | - [pre-commit](https://pre-commit.com) 12 | - [graphviz](https://graphviz.org/download/) 13 | - [ansible](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) 14 | 15 | 16 | # Terraform documentation 17 | 18 | ### 1. Create a terraform.tfvars file 19 | 20 | ```bash 21 | cp terraform.tfvars.template terraform.tfvars 22 | ``` 23 | 24 | The `terraform.tfvars` is used to specify Terraform variables such as the number of ec2 instances to create, and the local machine IP or VPN IP range which should be allowed for inbound ssh connection. 25 | 26 | Example of `terraform.tfvars` file: 27 | ``` 28 | aws_pem_key_name = "mypersonalkeypair" 29 | environment = "dev" 30 | instance_count = 1 31 | provisioning_logs = "~/path/to/tf_provisioning.log" 32 | tag_name = "Dev deployment" 33 | vpn_ip = "123.123.123.123/32" 34 | ``` 35 | 36 | ### 2. Configure Ansible 37 | 38 | Configure ansible variables as per the instructions under `## How to run the playbook`. 39 | 40 | ### 3. Create and configure infrastructure 41 | 42 | The `make apply` command can be run to automatically create and configure the remote servers. 43 | 44 | Other useful make commands can be found in the `Makefile`. 45 | 46 | ### Other useful Terraform commands 47 | 48 | Get a human-readable output from a state or plan file. 49 | ``` 50 | terraform show 51 | ``` 52 | 53 | Extract the value of an output variable from the state file. 54 | ``` 55 | terraform output 56 | ``` 57 | 58 | Automatically destroy all infrastructure without the user prompt. 59 | ``` 60 | terraform destroy --auto-approve 61 | ``` 62 | 63 | Commands used for State Management 64 | ``` 65 | terraform state show aws_instance.myec2 66 | terraform state list 67 | terraform state mv aws_instance.webapp aws_instance.myec2 68 | terraform state pull | jq [] 69 | terraform state rm aws_instance.myec2 70 | ``` 71 | 72 | Terraform Workspace commands 73 | ``` 74 | terraform workspace -h 75 | terraform workspace show 76 | terraform workspace new dev 77 | terraform workspace new prd 78 | terraform workspace list 79 | terraform workspace select dev 80 | ``` 81 | 82 | Test Terraform functions via the console 83 | ``` 84 | terraform console 85 | ``` 86 | 87 | # Ansible documentation 88 | 89 | The Ansible playbooks are used to provision ec2 instances with git repositories and development software packages such as docker, anaconda and poetry. 90 | 91 | These playbooks are meant to be run automatically from a terraform module, following the creation of ec2 instances. 92 | 93 | ------------------------------------------ 94 | 95 | ## Overview 96 | 97 | #### Ansible inventories 98 | 99 | Ansible inventories are a pattern for defining and grouping managed remote hosts. They are located in `ansible/inventories/`. The addresses of the machines that you want to configure are defined in the hosts file under inventories. You can also specify the ssh keys associated with these hosts in this file as well. 100 | 101 | Below is an example of `ansible/inventories/staging/hosts` file which contains a list of remote server public IPs and the ssh key file for Ansible to access the instances. 102 | 103 | ``` 104 | [staging] 105 | 18.134.226.25 106 | 3.10.227.148 107 | 108 | [staging:vars] 109 | ansible_ssh_private_key_file=~/.ssh/terraform_login_key.pem 110 | ansible_user=ec2-user 111 | ``` 112 | 113 | #### Ansible playbooks 114 | 115 | Playbooks contain the steps which are set to execute on a particular machine. 116 | 117 | This repository contains a `staging.yaml` and `production.yaml` playbooks. 118 | 119 | 120 | #### Ansible roles 121 | 122 | Roles can be found in `ansible/roles/common/` and are ways of automatically loading certain vars_files, tasks, and handlers based on a known file structure. Grouping content by roles also allows easy sharing of roles with other users. 123 | 124 | For example, the `docker_deployment.yml` playbook runs the `common` role, which in turn, runs a set of tasks defined in `ansible/roles/common/tasks/main.yaml`. 125 | 126 | 127 | ## How to run the playbook 128 | 129 | #### Set environment variables for Ansible 130 | 131 | The following environment variables are expected to be set in the `.env` file. 132 | ``` 133 | export ANSIBLE_HOST_KEY_CHECKING=False 134 | export ANSIBLE_VAULT_PASSWORD_FILE=~/.ansible_vault_pass 135 | export DOCKER_USER= 136 | ``` 137 | 138 | #### Ansible secrets 139 | 140 | Variables that we use for sensitive information like passwords are managed by ansible vault. These are stored in an encrypted file, `secrets.yaml`. A password is required to decrypt this file. For automation we can specify this password in a file stored somewhere secure outside of source control. 141 | 142 | ```bash 143 | touch ~/.ansible_vault_pass 144 | echo "${MY_ANSIBLE_VAULT_PASSPRASE}" > ~/.ansible_vault_pass 145 | export ANSIBLE_VAULT_PASSWORD_FILE=~/.ansible_vault_pass 146 | ansible-vault encrypt_string '' --name '' 147 | ``` 148 | 149 | The encrypted variable can then be stored as an ansible secret in the `secrets.yaml` to be used in ansible roles and playbooks. 150 | 151 | So that ansible knows where to look for his file, you should either specify it when running the playbooks with the command line argument `--vault-password-file` or by exporting the `ANSIBLE_VAULT_PASSWORD_FILE` environment variable. 152 | 153 | The current playbook expects a `dockerhub_password` secret so that Ansible can log in to dockerhub on the remote servers. 154 | 155 | 156 | #### Running a playbook 157 | 158 | The following command can be run to start the `staging.yaml` playbook. 159 | ```bash 160 | cd ansible 161 | source .env 162 | ansible-playbook -i inventories staging.yaml -v --timeout 60 163 | ``` 164 | -------------------------------------------------------------------------------- /app/main/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-01-15 12:42 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.db import migrations, models 6 | 7 | import main.mixins 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Category", 19 | fields=[ 20 | ("id", models.AutoField(primary_key=True, serialize=False)), 21 | ( 22 | "category_name", 23 | models.CharField( 24 | max_length=200, unique=True, verbose_name="Nom de la catégorie" 25 | ), 26 | ), 27 | ("summary", models.TextField()), 28 | ("image", models.ImageField(upload_to="images/", verbose_name="Photo")), 29 | ("category_slug", models.SlugField(unique=True)), 30 | ], 31 | options={"verbose_name": "Catégorie", "verbose_name_plural": "Catégories",}, 32 | bases=(models.Model, main.mixins.BaseModelMixin), 33 | ), 34 | migrations.CreateModel( 35 | name="Item", 36 | fields=[ 37 | ("id", models.AutoField(primary_key=True, serialize=False)), 38 | ( 39 | "item_name", 40 | models.CharField( 41 | max_length=200, unique=True, verbose_name="Nom de la recette" 42 | ), 43 | ), 44 | ("summary", models.CharField(max_length=200)), 45 | ("image", models.ImageField(upload_to="images/", verbose_name="Photo")), 46 | ( 47 | "content", 48 | models.TextField( 49 | default='

 ✅ Ingrédients

\n
    \n
  1. Ingredient1
  2. \n
  3. Ingredient2
  4. \n
\n

 

\n

🔪 Préparation

\n
    \n
  1. Instruction1
  2. \n
  3. Instruction2
  4. \n
\n

 

\n

🕗 Cuisson

\n
    \n
  1. Instruction1
  2. \n
  3. Instruction2
  4. \n
\n

 

\n

 

\n

Bon appétit! 👩🏻‍🍳

', 50 | verbose_name="Contenu", 51 | ), 52 | ), 53 | ( 54 | "date_published", 55 | models.DateTimeField( 56 | default=django.utils.timezone.now, verbose_name="date published" 57 | ), 58 | ), 59 | ("item_slug", models.SlugField(unique=True)), 60 | ("views", models.IntegerField(default=0)), 61 | ( 62 | "category_name", 63 | models.ForeignKey( 64 | default=1, 65 | on_delete=django.db.models.deletion.SET_DEFAULT, 66 | to="main.category", 67 | verbose_name="Catégorie", 68 | ), 69 | ), 70 | ], 71 | options={"verbose_name": "Recettes", "verbose_name_plural": "Recettes",}, 72 | bases=(models.Model, main.mixins.BaseModelMixin), 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /app/tests/mocks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module defines classes to build mock category 3 | and item objects for unit-tests 4 | """ 5 | 6 | from typing import Dict, List 7 | 8 | from django.contrib.auth.models import User 9 | 10 | from main.models import Category, Item 11 | 12 | 13 | class MockUser: 14 | """Class to create Mock User data and objects""" 15 | 16 | @staticmethod 17 | def mock_user_data() -> Dict: 18 | """Mock user data""" 19 | return dict( 20 | username="mock_user_data", email="mydummy@mail.com", password="dummypass", 21 | ) 22 | 23 | @staticmethod 24 | def mock_user_object() -> User: 25 | """Mock User Object""" 26 | return User.objects.create_user(**MockUser.mock_user_data()) 27 | 28 | @staticmethod 29 | def mock_invalid_user_data() -> Dict: 30 | """Mock user form data""" 31 | return dict(username="unknown_user", password="unknown_pass") 32 | 33 | @staticmethod 34 | def mock_user_form_data() -> Dict: 35 | """Mock user form data""" 36 | return dict( 37 | username="mock_user_form_data", 38 | email="mydummy@mail.com", 39 | password1="dummypass", 40 | password2="dummypass", 41 | ) 42 | 43 | 44 | class MockCategory: 45 | """Class to create Mock Category objects for testing""" 46 | 47 | DEFAULT_ID = 1 48 | DEFAULT_CATEGORY_NAME = "Category " 49 | DEFAULT_SUMMARY = "summary for category " 50 | DEFAULT_IMG_NAME = "img-url" 51 | DEFAULT_IMG_EXT = "png" 52 | 53 | @staticmethod 54 | def default_category(_id: int = DEFAULT_ID, **kwargs) -> Category: 55 | """ 56 | Generates a default mock Category object 57 | 58 | Distincts Category objects can be created: 59 | - By providing unique `_id` values. 60 | - And/or by providing a number of custom kwargs. 61 | 62 | Eg. 63 | custom_category=MockCategory.default_category( 64 | _id=235, 65 | category_name="Super Category " 66 | ) 67 | will generates a Category object with the following 68 | attributes: 69 | * id = 235 70 | * category_name = "Super Category 235" 71 | * summary = "summary for category 235" 72 | * image = "img-url-235.png" 73 | """ 74 | 75 | category_data = { 76 | "id": _id, 77 | "category_name": kwargs.get( 78 | "category_name", f"{MockCategory.DEFAULT_CATEGORY_NAME}{_id}" 79 | ), 80 | "summary": kwargs.get("summary", f"{MockCategory.DEFAULT_SUMMARY}{_id}"), 81 | "image": kwargs.get( 82 | "image", 83 | f"{MockCategory.DEFAULT_IMG_NAME}{_id}.{MockCategory.DEFAULT_IMG_EXT}", 84 | ), 85 | } 86 | 87 | dummy_category = Category.create(category_data) 88 | 89 | return dummy_category 90 | 91 | @staticmethod 92 | def default_categories(categories_count: int, **kwargs) -> List[Category]: 93 | """ 94 | Generates a list of default mock Category objects. 95 | 96 | In the same way as MockCategory.default_category(), 97 | the default Category attribute can be overriden. 98 | Eg. 99 | custom_categories=MockCategory.default_categories( 100 | categories_count=5 101 | category_name="Super Category " 102 | ) 103 | """ 104 | 105 | category_ids = [MockCategory.DEFAULT_ID + i for i in range(categories_count)] 106 | default_categories = [ 107 | MockCategory.default_category(_id, **kwargs) 108 | for _id in category_ids 109 | if _id > 0 110 | ] 111 | return default_categories 112 | 113 | 114 | class MockItem: 115 | """Class to create Mock Item objects for testing""" 116 | 117 | DEFAULT_ID = 1 118 | DEFAULT_ITEM_NAME = "Item " 119 | DEFAULT_SUMMARY = "summary for item " 120 | DEFAULT_IMG_NAME = "img-url" 121 | DEFAULT_IMG_EXT = "png" 122 | DEFAULT_CONTENT = "content for item " 123 | DEFAULT_DATE = "2020-05-22 19:49:50+00:00" 124 | DEFAULT_CATEGORY = MockCategory.DEFAULT_ID 125 | 126 | @staticmethod 127 | def default_item( 128 | parent_category: Category, item_id: int = DEFAULT_ID, **kwargs 129 | ) -> Item: 130 | """ 131 | Generates a default mock Item object 132 | 133 | Distinct Item objects can be created: 134 | - By providing unique `_id` values. 135 | - And/or by providing a number of custom kwargs. 136 | 137 | One thing to note is the `parent_category` argument, 138 | which is the Category object the Item "belong to". 139 | """ 140 | assert ( 141 | Category.objects.filter(category_name=parent_category.category_name) 142 | is not None 143 | ), f"Parent category {parent_category} does not exist in the databse." 144 | 145 | _id = f"{parent_category.id}-{item_id}" 146 | 147 | item_data = { 148 | "item_name": kwargs.get("item_name", f"{MockItem.DEFAULT_ITEM_NAME}{_id}"), 149 | "summary": kwargs.get("summary", f"{MockItem.DEFAULT_SUMMARY}{_id}"), 150 | "image": kwargs.get( 151 | "image", 152 | f"{MockCategory.DEFAULT_IMG_NAME}{_id}.{MockCategory.DEFAULT_IMG_EXT}", 153 | ), 154 | "content": kwargs.get("content", f"{MockItem.DEFAULT_CONTENT}{_id}"), 155 | "date_published": kwargs.get("date_published", f"{MockItem.DEFAULT_DATE}",), 156 | "category_name": parent_category, 157 | } 158 | 159 | dummy_item = Item.create(item_data) 160 | 161 | return dummy_item 162 | 163 | @staticmethod 164 | def default_items( 165 | items_count: int, parent_category: Category, **kwargs 166 | ) -> List[Item]: 167 | """Generates a list of default items""" 168 | 169 | item_ids = [MockItem.DEFAULT_ID + i for i in range(items_count)] 170 | default_items = list( 171 | map( 172 | lambda _id: MockItem.default_item(parent_category, _id, **kwargs), 173 | item_ids, 174 | ) 175 | ) 176 | 177 | return default_items 178 | -------------------------------------------------------------------------------- /app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """This module defines common objects used across tests""" 2 | 3 | from typing import Dict, List 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | from django.contrib.auth.models import User 8 | 9 | from app.helpers.constants import THUMBNAIL_SUFFIX 10 | from main.forms import ContactForm 11 | from main.models import Category, Item 12 | from tests.mocks import MockCategory, MockItem, MockUser 13 | 14 | 15 | def save_mock_category(monkeypatch, category: Category) -> None: 16 | """ 17 | Mock the resize_image() function to prevent the need of creating and 18 | processing dummy images when saving Category objects. 19 | """ 20 | mock_resize_image = Mock(return_value=category.image) 21 | monkeypatch.setattr(Category, "resize_image", mock_resize_image) 22 | category.save() 23 | mock_resize_image.assert_called_once_with(category.image) 24 | 25 | 26 | def save_mock_item(monkeypatch, item: Item) -> None: 27 | """ 28 | Mock the resize_image() function to prevent the need of creating and 29 | processing dummy images when saving Item objects. 30 | """ 31 | mock_resize_image = Mock(return_value=item.image) 32 | monkeypatch.setattr(Item, "resize_image", mock_resize_image) 33 | item.save() 34 | mock_resize_image.assert_called_once_with(item.image, suffix=THUMBNAIL_SUFFIX) 35 | 36 | 37 | ########################## 38 | # 39 | # Category Fixtures 40 | # 41 | ########################## 42 | @pytest.fixture 43 | def mock_default_category() -> Category: 44 | """Returns a default category object (unsaved)""" 45 | return MockCategory.default_category() 46 | 47 | 48 | @pytest.fixture 49 | def load_default_category(monkeypatch) -> Category: 50 | """Saves a default category object, and return the object""" 51 | default_category = MockCategory.default_category() 52 | save_mock_category(monkeypatch, default_category) 53 | return default_category 54 | 55 | 56 | @pytest.fixture 57 | def mock_default_categories() -> List[Category]: 58 | """Returns a default category objects (unsaved)""" 59 | return MockCategory.default_categories(categories_count=5) 60 | 61 | 62 | @pytest.fixture 63 | def load_default_categories(monkeypatch) -> List[Category]: 64 | """Saves default category objects, and return objects""" 65 | default_categories = MockCategory.default_categories(categories_count=5) 66 | for default_category in default_categories: 67 | save_mock_category(monkeypatch, default_category) 68 | return default_categories 69 | 70 | 71 | ########################## 72 | # 73 | # Item Fixtures 74 | # 75 | ########################## 76 | @pytest.fixture 77 | def mock_default_item(monkeypatch) -> Item: 78 | """Return a default item object (unsaved)""" 79 | default_category = MockCategory.default_category() 80 | save_mock_category(monkeypatch, default_category) 81 | return MockItem.default_item(parent_category=default_category) 82 | 83 | 84 | @pytest.fixture 85 | def load_default_item(monkeypatch) -> Item: 86 | """Save a default item object, and return the object""" 87 | default_category = MockCategory.default_category() 88 | save_mock_category(monkeypatch, default_category) 89 | default_item = MockItem.default_item(parent_category=default_category) 90 | save_mock_item(monkeypatch, default_item) 91 | return mock_default_item 92 | 93 | 94 | @pytest.fixture 95 | def mock_default_items() -> List[Item]: 96 | """Return a list of default item objects (unsaved)""" 97 | return MockItem.default_items( 98 | items_count=5, parent_category=MockCategory.default_category() 99 | ) 100 | 101 | 102 | @pytest.fixture 103 | def load_default_items(monkeypatch) -> List[Item]: 104 | """Save default item objects, and return the objects""" 105 | default_category = MockCategory.default_category() 106 | save_mock_category(monkeypatch, default_category) 107 | default_items = MockItem.default_items( 108 | items_count=5, parent_category=default_category 109 | ) 110 | # pylint: disable=expression-not-assigned 111 | [save_mock_item(monkeypatch, itm) for itm in default_items] 112 | return default_items 113 | 114 | 115 | @pytest.fixture 116 | def load_default_items_and_cats( 117 | monkeypatch, categories_count=5, items_count=5 118 | ) -> List[Item]: 119 | """ 120 | Creates and save a given number of category objects, and for each one, 121 | it creates/saves a given number of (children) item objects. 122 | 123 | Eg. By setting categories_count=5; and items_count=5; 124 | This will create and load a total of 5 categories, 125 | and 25 items into the database. 126 | """ 127 | created_categories = MockCategory.default_categories(categories_count) 128 | created_items = [] 129 | for category in created_categories: 130 | save_mock_category(monkeypatch, category) 131 | items = MockItem.default_items(items_count, parent_category=category) 132 | # pylint: disable=expression-not-assigned 133 | [save_mock_item(monkeypatch, itm) for itm in items] 134 | created_items.append(items) 135 | return created_categories, created_items 136 | 137 | 138 | ########################## 139 | # 140 | # Other Fixtures 141 | # 142 | ########################## 143 | 144 | 145 | @pytest.fixture 146 | def mock_contact_form() -> ContactForm: 147 | """Fixture for a valid ContactForm""" 148 | mock_form = ContactForm() 149 | mock_form.name = "dummy name" 150 | mock_form.contact_email = "dummy@mail.com" 151 | mock_form.subject = "dummy subject" 152 | mock_form.message = "dummy content" 153 | return mock_form 154 | 155 | 156 | @pytest.fixture 157 | def mock_user_dict() -> Dict: 158 | """Fixture to create the default user data""" 159 | return MockUser.mock_user_data() 160 | 161 | 162 | @pytest.fixture 163 | def mock_user() -> User: 164 | """Fixture to create the default user""" 165 | return MockUser.mock_user_object() 166 | 167 | 168 | @pytest.fixture 169 | def mock_invalid_user_dict() -> Dict: 170 | """User credentials which are notsaved in the database""" 171 | return MockUser.mock_invalid_user_data() 172 | 173 | 174 | @pytest.fixture 175 | def mock_user_form_dict() -> Dict: 176 | """Fixture to create a default user form data""" 177 | return MockUser.mock_user_form_data() 178 | -------------------------------------------------------------------------------- /app/portfolio/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for portfolio project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | from app import config 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | PORTFOLIO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | BASE_DIR = os.path.dirname(PORTFOLIO_DIR) 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = config.SECRET_KEY 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | # To be set in child settings files 27 | DEBUG = None 28 | 29 | ALLOWED_HOSTS = ["*"] 30 | 31 | # django-debug-toolbar shows up only if Debug=True and request IP in INTERNAL_IPS 32 | INTERNAL_IPS = [ 33 | "127.0.0.1", 34 | ] 35 | 36 | TINYMCE_DEFAULT_CONFIG = { 37 | "height": 360, 38 | "width": 1120, 39 | "cleanup_on_startup": True, 40 | "custom_undo_redo_levels": 20, 41 | "selector": "textarea", 42 | "theme": "modern", 43 | "plugins": """ 44 | textcolor save link image media preview codesample contextmenu 45 | table code lists fullscreen insertdatetime nonbreaking 46 | contextmenu directionality searchreplace wordcount visualblocks 47 | visualchars code fullscreen autolink lists charmap print hr 48 | anchor pagebreak 49 | """, 50 | "toolbar1": """ 51 | fullscreen preview bold italic underline | fontselect, 52 | fontsizeselect | forecolor backcolor | alignleft alignright | 53 | aligncenter alignjustify | indent outdent | bullist numlist table | 54 | | link image media | codesample | 55 | """, 56 | "toolbar2": """ 57 | visualblocks visualchars | 58 | charmap hr pagebreak nonbreaking anchor | code | 59 | """, 60 | "contextmenu": "formats | link image", 61 | "menubar": True, 62 | "statusbar": True, 63 | } 64 | 65 | # Application definition 66 | INSTALLED_APPS = [ 67 | "django.contrib.admin", 68 | "django.contrib.auth", 69 | "django.contrib.contenttypes", 70 | "django.contrib.sessions", 71 | "django.contrib.messages", 72 | "django.contrib.staticfiles", 73 | "main", 74 | "tinymce", 75 | "materializecssform", 76 | "debug_toolbar", 77 | "rest_framework", 78 | "django_filters", 79 | ] 80 | 81 | MIDDLEWARE = [ 82 | "django.middleware.security.SecurityMiddleware", 83 | "django.contrib.sessions.middleware.SessionMiddleware", 84 | "django.middleware.locale.LocaleMiddleware", 85 | "django.middleware.common.CommonMiddleware", 86 | "django.middleware.csrf.CsrfViewMiddleware", 87 | "django.contrib.auth.middleware.AuthenticationMiddleware", 88 | "django.contrib.messages.middleware.MessageMiddleware", 89 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 90 | "debug_toolbar.middleware.DebugToolbarMiddleware", 91 | ] 92 | 93 | ROOT_URLCONF = "portfolio.urls" 94 | 95 | TEMPLATES = [ 96 | { 97 | "BACKEND": "django.template.backends.django.DjangoTemplates", 98 | "DIRS": [os.path.join(BASE_DIR, "templates")], 99 | "APP_DIRS": True, 100 | "OPTIONS": { 101 | "context_processors": [ 102 | "django.template.context_processors.debug", 103 | "django.template.context_processors.media", 104 | "django.template.context_processors.request", 105 | "django.contrib.auth.context_processors.auth", 106 | "django.contrib.messages.context_processors.messages", 107 | ], 108 | }, 109 | }, 110 | ] 111 | 112 | WSGI_APPLICATION = "portfolio.wsgi.application" 113 | 114 | # Password validation 115 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 116 | 117 | AUTH_PASSWORD_VALIDATORS = [ 118 | { 119 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # pylint: disable=line-too-long 120 | }, 121 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 122 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, 123 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, 124 | ] 125 | 126 | 127 | # Internationalization 128 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 129 | LANGUAGE_CODE = "fr" # en-us 130 | TIME_ZONE = "UTC" 131 | USE_I18N = True 132 | USE_L10N = True 133 | USE_TZ = True 134 | 135 | 136 | # Cache configuration - To be set in child settings files 137 | CACHE_TTL = None 138 | 139 | # Database - To be set in child settings files 140 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 141 | DATABASES = {} 142 | 143 | 144 | # File storage - To be set in child settings files 145 | UPLOADS_FOLDER_PATH = "images/" 146 | STATIC_URL = None 147 | STATIC_ROOT = None 148 | MEDIA_URL = None 149 | MEDIA_ROOT = None 150 | STATICFILES_DIRS = None 151 | 152 | 153 | # Write logging from the django logger to a local file 154 | APP_DIR = Path(__file__).resolve().parent.parent.parent 155 | LOG_DIR_PATH = APP_DIR / "logs" 156 | LOG_DIR_PATH.mkdir(parents=True, exist_ok=True) 157 | LOG_INFO_FILE_PATH = LOG_DIR_PATH / "info.log" 158 | if not LOG_INFO_FILE_PATH.is_file(): 159 | with open(LOG_INFO_FILE_PATH, "w", encoding="utf-8") as f: 160 | f.write("\n") 161 | 162 | LOGGING = { 163 | "version": 1, 164 | "disable_existing_loggers": False, 165 | "formatters": { 166 | "standard": {"format": "%(asctime)s %(levelname)s %(name)s %(message)s"}, 167 | }, 168 | "handlers": { 169 | "file": { 170 | "level": "INFO", 171 | "class": "logging.FileHandler", 172 | "filename": LOG_INFO_FILE_PATH, 173 | "formatter": "standard", 174 | }, 175 | }, 176 | "loggers": {"": {"handlers": ["file"], "level": "INFO", "propagate": True},}, 177 | } 178 | 179 | REST_FRAMEWORK = { 180 | "DEFAULT_PERMISSION_CLASSES": [ 181 | "rest_framework.permissions.IsAuthenticated", 182 | "rest_framework.permissions.AllowAny", 183 | ] 184 | } 185 | -------------------------------------------------------------------------------- /app/main/api_views.py: -------------------------------------------------------------------------------- 1 | # from django.core.cache import cache 2 | # from django_filters.rest_framework import DjangoFilterBackend 3 | # from rest_framework import status 4 | # from rest_framework.filters import SearchFilter 5 | # from rest_framework.generics import ( 6 | # CreateAPIView, 7 | # GenericAPIView, 8 | # ListAPIView, 9 | # RetrieveUpdateDestroyAPIView, 10 | # ) 11 | # from rest_framework.pagination import LimitOffsetPagination 12 | # from rest_framework.permissions import AllowAny, IsAuthenticated 13 | # from rest_framework.response import Response 14 | 15 | # from .models import Category, Item 16 | # from .serializers import CategorySerializer, ItemSerializer, StatSerializer 17 | 18 | 19 | # class Pagination(LimitOffsetPagination): 20 | # default_limit = 10 21 | # max_limit = 100 22 | 23 | 24 | # class CategoryList(ListAPIView): 25 | # permission_classes = [AllowAny] 26 | 27 | # # QuerySet 28 | # queryset = Category.objects.all().order_by("id") 29 | 30 | # # Serializer 31 | # serializer_class = CategorySerializer 32 | 33 | # # Filtering 34 | # filter_backends = (DjangoFilterBackend, SearchFilter) 35 | # filter_fields = ("id",) 36 | # search_fields = ("category_name", "summary") 37 | 38 | # # Pagination 39 | # pagination_class = Pagination 40 | 41 | # def get_queryset(self): 42 | # """ 43 | # Override this method to add some custom filtering 44 | # for e.g. based on a boolean field 45 | # """ 46 | # return super().get_queryset() 47 | 48 | 49 | # class CategoryCreate(CreateAPIView): 50 | # permission_classes = [IsAuthenticated] 51 | 52 | # serializer_class = CategorySerializer 53 | 54 | # def create(self, request, *args, **kwargs): 55 | # """ 56 | # Overrides CREATE API method to validate user input, 57 | # E.g: validation of a decimal value 58 | # if request.data.get('price') <= 0.0: 59 | # raise ValidationError({'err_type': 'Must be above £0.00'}) 60 | # """ 61 | # return super().create(request, *args, **kwargs) 62 | 63 | 64 | # class CategoryRetrieveUpdateDestroyAPIView(RetrieveUpdateDestroyAPIView): 65 | # permission_classes = [IsAuthenticated] 66 | 67 | # queryset = Category.objects.all() 68 | # lookup_field = "id" 69 | # serializer_class = CategorySerializer 70 | 71 | # def delete(self, request, *args, **kwargs): 72 | # """ 73 | # Overrides HTTP DELETE method to update cache 74 | # """ 75 | # response = super().delete(request, *args, **kwargs) 76 | # if response.status_code == status.HTTP_204_NO_CONTENT: 77 | # category_id = request.data.get("id") 78 | # cache.delete(f"category_data_{category_id}") 79 | # return response 80 | 81 | # def update(self, request, *args, **kwargs): 82 | # """ 83 | # Overrides UPDATE API method to update cache 84 | # """ 85 | # response = super().update(request, *args, **kwargs) 86 | # if response.status_code == status.HTTP_200_OK: 87 | # category = request.data 88 | # cache.set( 89 | # f'category_data_{category["id"]}', 90 | # { 91 | # "category_name": category["category_name"], 92 | # "summary": category["summary"], 93 | # "image": category["image"], 94 | # "category_slug": category["category_slug"], 95 | # }, 96 | # ) 97 | # return response 98 | 99 | 100 | # class CategoryStats(GenericAPIView): 101 | # """ 102 | # GenericAPIView class to generate category stats 103 | # """ 104 | 105 | # permission_classes = [AllowAny] 106 | 107 | # lookup_field = "id" 108 | # serializer_class = StatSerializer 109 | # queryset = Category.objects.all() 110 | 111 | # def get(self, request, format=None, id=None): 112 | # """ 113 | # Overrides the HTTP GET method to 114 | # return a stats dictionary response which include 115 | # the total number of items views for the selected category 116 | # """ 117 | # category = self.get_object() 118 | # items_in_category = Item.objects.filter(category_name=category) 119 | # all_views_count = sum([item.views for item in items_in_category]) 120 | # serializer = StatSerializer({"stats": {"all_items_views": all_views_count}}) 121 | # return Response(serializer.data) 122 | 123 | 124 | # class ItemList(ListAPIView): 125 | # permission_classes = [AllowAny] 126 | 127 | # # QuerySet 128 | # queryset = Item.objects.all().order_by("id") 129 | 130 | # # Serializer 131 | # serializer_class = ItemSerializer 132 | 133 | # # Filtering 134 | # filter_backends = (DjangoFilterBackend, SearchFilter) 135 | # filter_fields = ("id",) 136 | # search_fields = ("item_name", "summary") 137 | 138 | # # Pagination 139 | # pagination_class = Pagination 140 | 141 | # def get_queryset(self): 142 | # """ 143 | # Override this method to add some custom filtering 144 | # for e.g. based on a boolean field 145 | # """ 146 | # return super().get_queryset() 147 | 148 | 149 | # class ItemCreate(CreateAPIView): 150 | # permission_classes = [IsAuthenticated] 151 | 152 | # serializer_class = ItemSerializer 153 | 154 | # def create(self, request, *args, **kwargs): 155 | # """ 156 | # Overrides the CREATE API method to validate user input, 157 | # E.g: validation of a decimal value 158 | # if request.data.get('price') <= 0.0: 159 | # raise ValidationError({'err_type': 'Must be above £0.00'}) 160 | # """ 161 | # return super().create(request, *args, **kwargs) 162 | 163 | 164 | # class ItemRetrieveUpdateDestroyAPIView(RetrieveUpdateDestroyAPIView): 165 | # permission_classes = [IsAuthenticated] 166 | 167 | # queryset = Item.objects.all() 168 | # lookup_field = "id" 169 | # serializer_class = ItemSerializer 170 | 171 | # def delete(self, request, *args, **kwargs): 172 | # """ 173 | # Overrides method to update cache 174 | # """ 175 | # response = super().delete(request, *args, **kwargs) 176 | # if response.status_code == status.HTTP_204_NO_CONTENT: 177 | # item_id = request.data.get("id") 178 | # cache.delete(f"item_data_{item_id}") 179 | # return response 180 | 181 | # def update(self, request, *args, **kwargs): 182 | # """ 183 | # Overrides UPDATE API method to update cache 184 | # """ 185 | # response = super().update(request, *args, **kwargs) 186 | # if response.status_code == status.HTTP_200_OK: 187 | # item = request.data 188 | # cache.set( 189 | # f'item_data_{item["id"]}', 190 | # { 191 | # "item_name": item["item_name"], 192 | # "summary": item["summary"], 193 | # "content": item["content"], 194 | # "summary": item["summary"], 195 | # "item_slug": item["item_slug"], 196 | # "category_name": item["category_name"], 197 | # "views": item["views"], 198 | # }, 199 | # ) 200 | # return response 201 | -------------------------------------------------------------------------------- /app/tests/test_models/test_category.py: -------------------------------------------------------------------------------- 1 | """This module defines tests for the Category django model""" 2 | 3 | from typing import List, Tuple 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | from django.conf import settings 8 | from django.db.models.fields.files import ImageFieldFile 9 | from PIL import UnidentifiedImageError 10 | 11 | from helpers.constants import CROP_SIZE, IMG_EXT 12 | from main.models import Category 13 | from tests.mocks import MockCategory 14 | from tests.utils import ( 15 | check_image_attributes, 16 | create_dummy_file, 17 | create_dummy_png_image, 18 | ) 19 | 20 | 21 | @pytest.mark.django_db(transaction=True) 22 | class TestCategory: 23 | """Tests for the Item django model""" 24 | 25 | # pylint: disable=no-self-use 26 | def test_create_mock_category(self, mock_default_category: Category): 27 | """Test category created with the expected attributes""" 28 | # Given: Default field values defined in mock.py 29 | # When: the mock_default_category fixture is called 30 | _id = MockCategory.DEFAULT_ID 31 | attr_mapping = { 32 | mock_default_category.category_name: f"{MockCategory.DEFAULT_CATEGORY_NAME}{_id}", # pylint: disable=line-too-long 33 | mock_default_category.summary: f"{MockCategory.DEFAULT_SUMMARY}{_id}", 34 | mock_default_category.image: f"{MockCategory.DEFAULT_IMG_NAME}{_id}.{MockCategory.DEFAULT_IMG_EXT}", # pylint: disable=line-too-long 35 | } 36 | 37 | # Then: Category object fields have been assigned correctly 38 | assert all( 39 | cat_attr == dummy_var for cat_attr, dummy_var in attr_mapping.items() 40 | ) 41 | 42 | # pylint: disable=no-self-use 43 | def test_attr_types(self, mock_default_category: Category): 44 | """Test category created with the expected attributes types""" 45 | # Given: Default field values defined in mock.py 46 | # When: the mock_default_category fixture is called 47 | type_mapping = { 48 | mock_default_category.category_name: str, 49 | mock_default_category.summary: str, 50 | mock_default_category.image: ImageFieldFile, 51 | mock_default_category.category_slug: str, 52 | } 53 | # Then: Category object fields have the correct types 54 | assert all( 55 | isinstance(attr, attr_type) for attr, attr_type in type_mapping.items() 56 | ) 57 | 58 | # pylint: disable=no-self-use 59 | def test_category_str_cast(self, mock_default_category: Category): 60 | """Test category str() method is overridden""" 61 | assert str(mock_default_category) == mock_default_category.category_name 62 | 63 | # pylint: disable=no-self-use 64 | def test_category_repr_cast(self, mock_default_category: Category): 65 | """Tests Category repr() method is overridden""" 66 | assert repr(mock_default_category) == ( 67 | f"Category=(id={mock_default_category.id},category_name=" 68 | f"{mock_default_category.category_name},category_slug=" 69 | f"{mock_default_category.category_slug})" 70 | ) 71 | 72 | # pylint: disable=no-self-use 73 | def test_load_categories(self, load_default_categories: List[Category]): 74 | """Test that load_default_categories does insert Category objects in the DB""" 75 | assert Category.objects.all().count() == len(load_default_categories) 76 | 77 | # pylint: disable=no-self-use 78 | def test_mock_categories(self, mock_default_categories: List[Category]): 79 | """ 80 | Test that mock_default_categories fixture does return Category objects 81 | but will not save them into the DB 82 | """ 83 | assert Category.objects.all().count() == 0 84 | assert all(isinstance(obj, Category) for obj in mock_default_categories) 85 | 86 | # pylint: disable=no-self-use 87 | def test_image_resize_called(self, monkeypatch, mock_default_category: Category): 88 | """Ensures the resize_image function is called when saving a category""" 89 | # Given: a mock resize_image function 90 | monkeypatch.setattr( 91 | Category, 92 | "resize_image", 93 | mock_resize_image := Mock(return_value=mock_default_category.image), 94 | ) 95 | 96 | # When: the save method of a Category object is called 97 | mock_default_category.save() 98 | 99 | # Then: The resize_image function is called 100 | mock_resize_image.assert_called_once_with(mock_default_category.image) 101 | 102 | @pytest.mark.parametrize( 103 | "initial_size", [(800, 1280), (2000, 200), (200, 2000), (100, 100)] 104 | ) 105 | @pytest.mark.parametrize("file_ext", ["png", "jpeg", "bmp", "tiff"]) 106 | # pylint: disable=no-self-use 107 | # pylint: disable=too-many-arguments 108 | def test_image_resize_success( 109 | self, 110 | monkeypatch, 111 | tmp_path, 112 | mock_default_category: Category, 113 | initial_size: Tuple[int, int], 114 | file_ext: str, 115 | ): 116 | """Ensure resize_image function does its job""" 117 | 118 | # Set Django to store media files to the tmp_path directory 119 | monkeypatch.setattr(settings, "MEDIA_ROOT", tmp_path) 120 | 121 | # Given: a category with a mock image 122 | mock_default_category.image = tmp_path / f"dummy_image_base_name.{file_ext}" 123 | create_dummy_png_image( 124 | tmp_path, mock_default_category.image.name, image_size=initial_size 125 | ) 126 | 127 | # Then: Image has its initial size before resizing 128 | check_image_attributes( 129 | mock_default_category.image, size=initial_size, ext=f".{file_ext}", 130 | ) 131 | 132 | # When: the save method calls resize_image 133 | mock_default_category.save() 134 | 135 | # Then: Image has its expected size after resizing 136 | check_image_attributes( 137 | mock_default_category.image, size=CROP_SIZE, ext=IMG_EXT, 138 | ) 139 | 140 | @pytest.mark.parametrize( 141 | "file_ext, exception", 142 | [ 143 | ("oops", UnidentifiedImageError), 144 | ("pdf", UnidentifiedImageError), 145 | ("txt", UnidentifiedImageError), 146 | ("docx", UnidentifiedImageError), 147 | ("xls", UnidentifiedImageError), 148 | ], 149 | ) 150 | # pylint: disable=too-many-arguments 151 | # pylint: disable=no-self-use 152 | def test_image_resize_failed( 153 | self, 154 | monkeypatch, 155 | tmp_path, 156 | mock_default_category: Category, 157 | file_ext: str, 158 | exception: Exception, 159 | ): 160 | """Expected Exception is raised when providing an invalid file format""" 161 | 162 | # Set Django to store media files to the tmp_path directory 163 | monkeypatch.setattr(settings, "MEDIA_ROOT", tmp_path) 164 | 165 | # Given: a category with a mock invalid file as an image attribute 166 | mock_default_category.image = tmp_path / f"invalid_image.{file_ext}" 167 | create_dummy_file(tmp_path, filename=mock_default_category.image.name) 168 | 169 | # When: The expected is raised when the resize_image function is called 170 | with pytest.raises(exception): 171 | mock_default_category.save() 172 | -------------------------------------------------------------------------------- /deployment/prod/cloudformation/parent-stack.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Metadata: 3 | License: Apache-2.0 4 | Description: Parent CloudFormation template to deploy a resilient, highly available web application to AWS 5 | 6 | Parameters: 7 | ASGCPUTargetValue: 8 | Description: Average CPU Target for each instance (eg. 60%) 9 | Type: Number 10 | MinValue: 20 11 | MaxValue: 80 12 | ASGDesiredCapacity: 13 | Description: Auto Scaling Group Desired Capacity 14 | Type: String 15 | ASGScheduledActionUpTimeHour: 16 | Default: '7' 17 | Type: String 18 | Description: Scale up time, for eg. '8' will cause the ASG to scale up at 8AM GMT. 19 | AllowedValues: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 20 | '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'] 21 | ASGScheduledActionDownTimeHour: 22 | Default: '3' 23 | Type: String 24 | Description: Scale down time, for eg. '23' will cause the ASG to scale down at 11PM GMT. 25 | AllowedValues: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 26 | '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'] 27 | ACMPublicSSLCertArn: 28 | Description: Public SSL Cert Arn stored in ACM. Must be in the same region as CloudFront (us-east-1). 29 | Type: String 30 | CodeDeployDimensionName: 31 | Description: Dimension Name for the Custom Metric API to push codedeploy deployment states to 32 | Type: String 33 | Default: DEPLOYMENT_STATE 34 | CodeDeployMetricName: 35 | Description: Metric Name for the Custom Metric API to push codedeploy deployment states to 36 | Type: String 37 | Default: CodeDeployDeploymentStates 38 | EC2LatestLinuxAmiId: 39 | Description: AWS managed SSM parameter to retrieve the latest Amazon Linux 2 AMI Id 40 | Type: AWS::SSM::Parameter::Value 41 | EC2InstanceType: 42 | Description: WebServer EC2 instance type 43 | Type: String 44 | AllowedValues: [t2.micro] 45 | ConstraintDescription: Must be a valid EC2 instance type. 46 | EC2VolumeSize: 47 | Description: Volume size mounted to EC2 48 | Type: Number 49 | MinValue: 8 50 | MaxValue: 20 51 | SetR53SubDomainAsStackName: 52 | Description: SSM parameter to set a R53 sub domain name as the stack name. Allowed values are [True, False]. If False, the root domain name is used. 53 | Type: AWS::SSM::Parameter::Value 54 | R53HostedZoneName: 55 | Description: The DNS name of an existing Amazon Route 53 hosted zone. For example, "mydomain.com" 56 | AllowedPattern: (?!-)[a-zA-Z0-9-.]{1,63}(? 66 | SSMParamNameRdsPostgresPassword: 67 | Description: SSM Parameter Name for the RDS password SecureString. 68 | Type: String 69 | SubnetListStr: 70 | # Type: List 71 | Type: String 72 | Default: subnet-103a1a79,subnet-28219264 73 | VpcId: 74 | Description: VpcId of the existing Virtual Private Cloud (VPC) 75 | Type: AWS::EC2::VPC::Id 76 | 77 | Conditions: 78 | SetR53SubDomainAsStackNameCondition: !Equals [!Ref SetR53SubDomainAsStackName, 'True'] 79 | 80 | Resources: 81 | NetworkStack: 82 | Type: AWS::CloudFormation::Stack 83 | Properties: 84 | TemplateURL: network/template.yaml 85 | Parameters: 86 | ALBSubnetsString: !Ref SubnetListStr 87 | ACMPublicSSLCertArn: !Ref ACMPublicSSLCertArn 88 | OAIUserId: !GetAtt StorageStack.Outputs.OAIUserId 89 | R53HostedZoneName: !Ref R53HostedZoneName 90 | R53SubDomain: !If [SetR53SubDomainAsStackNameCondition, !Ref AWS::StackName, ''] 91 | S3BucketDomainNameLogging: !GetAtt StorageStack.Outputs.S3BucketDomainNameLogging 92 | S3BucketNameLogging: !GetAtt StorageStack.Outputs.S3BucketNameLogging 93 | S3BucketDomainNameWebappStaticFiles: !GetAtt StorageStack.Outputs.S3BucketDomainNameWebappStaticFiles 94 | S3WebsiteBucketDomainName: !GetAtt StorageStack.Outputs.S3WebsiteBucketDomainName 95 | VpcId: !Ref VpcId 96 | ComputeStack: 97 | Type: AWS::CloudFormation::Stack 98 | Properties: 99 | TemplateURL: compute/template.yaml 100 | Parameters: 101 | ASGCPUTargetValue: !Ref ASGCPUTargetValue 102 | ASGDesiredCapacity: !Ref ASGDesiredCapacity 103 | ASGScheduledActionUpTimeHour: !Ref ASGScheduledActionUpTimeHour 104 | ASGScheduledActionDownTimeHour: !Ref ASGScheduledActionDownTimeHour 105 | ASGSubnetsString: !Ref SubnetListStr 106 | EC2SecurityGroupId: !GetAtt NetworkStack.Outputs.EC2SecurityGroupId 107 | EC2InstanceType: !Ref EC2InstanceType 108 | EC2AmiId: !Ref EC2LatestLinuxAmiId 109 | EC2VolumeSize: !Ref EC2VolumeSize 110 | ElasticacheRedisEndpoint: !GetAtt DatabaseStack.Outputs.RedisEndpoint 111 | RDSPostgresEndpoint: !GetAtt DatabaseStack.Outputs.PostgresEndpoint 112 | SSMParamNameRdsPostgresPassword: !Ref SSMParamNameRdsPostgresPassword 113 | S3BucketArnCodeDeployArtifacts: !GetAtt StorageStack.Outputs.S3BucketArnCodeDeployArtifacts 114 | S3BucketArnStaticFiles: !GetAtt StorageStack.Outputs.S3BucketArnStaticFiles 115 | S3BucketNameStaticFiles: !GetAtt StorageStack.Outputs.S3BucketNameWebappStaticFiles 116 | SNSTopicArn: !GetAtt ServerlessStack.Outputs.SNSTopicArn 117 | SESIdentityArn: !Ref SESIdentityArn 118 | SQSQueueArn: !GetAtt ServerlessStack.Outputs.SQSQueueArn 119 | SQSQueueUrl: !GetAtt ServerlessStack.Outputs.SQSQueueUrl 120 | TargetGroupArn: !GetAtt NetworkStack.Outputs.TargetGroupArn 121 | TargetGroupName: !GetAtt NetworkStack.Outputs.TargetGroupName 122 | WebsiteDomain: !GetAtt NetworkStack.Outputs.Route53RecordSetDomainName 123 | StorageStack: 124 | Type: AWS::CloudFormation::Stack 125 | Properties: 126 | TemplateURL: storage/template.yaml 127 | Parameters: 128 | ASGScheduledActionUpTimeHour: !Ref ASGScheduledActionUpTimeHour 129 | ASGScheduledActionDownTimeHour: !Ref ASGScheduledActionDownTimeHour 130 | R53HostedZoneName: !Ref R53HostedZoneName 131 | R53SubDomain: !If [SetR53SubDomainAsStackNameCondition, !Ref AWS::StackName, ''] 132 | ServerlessStack: 133 | Type: AWS::CloudFormation::Stack 134 | Properties: 135 | TemplateURL: serverless/template.yaml 136 | Parameters: 137 | CodeDeployDimensionName: !Ref CodeDeployDimensionName 138 | CodeDeployMetricName: !Ref CodeDeployMetricName 139 | SlackWebhookUrl: !Ref SSMParamSlackWebhookUrl 140 | DatabaseStack: 141 | Type: AWS::CloudFormation::Stack 142 | Properties: 143 | TemplateURL: database/template.yaml 144 | Parameters: 145 | AddRDSReadReplica: 'false' 146 | ElasticacheSecurityGroupId: !GetAtt NetworkStack.Outputs.ElastiCacheSecurityGroupId 147 | RDSSecurityGroupName: !GetAtt NetworkStack.Outputs.RDSSecurityGroupName 148 | SSMParamNameRdsPostgresPassword: !Ref SSMParamNameRdsPostgresPassword 149 | DashboardStack: 150 | Type: AWS::CloudFormation::Stack 151 | Properties: 152 | TemplateURL: dashboard/template.yaml 153 | Parameters: 154 | ALBFullName: !GetAtt NetworkStack.Outputs.ALBFullName 155 | AutoScalingGroupName: !GetAtt ComputeStack.Outputs.AutoScalingGroupName 156 | CloudFrontDistributionId: !GetAtt NetworkStack.Outputs.CloudFrontDistributionId 157 | CodeDeployDimensionName: !Ref CodeDeployDimensionName 158 | CodeDeployMetricName: !Ref CodeDeployMetricName 159 | DockerLogGroupName: !Join ['', ["'", !GetAtt ComputeStack.Outputs.DockerLogGroupName, "'"]] 160 | LogsOnly: 'true' 161 | TargetGroupFullName: !GetAtt NetworkStack.Outputs.TargetGroupFullName 162 | WebsiteUrl: !Join ['', [https://, !GetAtt NetworkStack.Outputs.Route53RecordSetDomainName]] 163 | 164 | Outputs: 165 | DashboardUrl: 166 | Value: !Join ['', [ 167 | 'https://', 168 | !Sub '${AWS::Region}', 169 | '.console.aws.amazon.com/cloudwatch/home?region=', 170 | !Sub '${AWS::Region}', 171 | '#dashboards:name=', 172 | !GetAtt 'DashboardStack.Outputs.DashboardName' 173 | ]] 174 | CodeDeployApplicationName: 175 | Value: !GetAtt ComputeStack.Outputs.CodeDeployApplicationName 176 | CodeDeployDeploymentGroupName: 177 | Value: !GetAtt ComputeStack.Outputs.CodeDeployDeploymentGroupName 178 | CodeDeployS3BucketName: 179 | Value: !GetAtt StorageStack.Outputs.S3BucketNameCodeDeployArtifacts 180 | PostgresRdsEndpoint: 181 | Value: !GetAtt DatabaseStack.Outputs.PostgresEndpoint 182 | RedisElasticacheEndpoint: 183 | Value: !GetAtt DatabaseStack.Outputs.RedisEndpoint 184 | --------------------------------------------------------------------------------