├── 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 |

{{item.date_published}}
12 |20 |24 |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 |
38 |42 |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 |
56 |60 |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 |
✅ Ingrédients
2 |7 |
🔪 Préparation
8 |13 |
🕗 Cuisson
14 |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=✅ Ingrédients
\n\n
🔪 Préparation
\n\n
🕗 Cuisson
\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