Skip to content

Commit

Permalink
Add cookiecutter Django app
Browse files Browse the repository at this point in the history
  • Loading branch information
nmenezes0 committed Feb 28, 2024
1 parent 8bd95c4 commit 20fbb97
Show file tree
Hide file tree
Showing 53 changed files with 1,625 additions and 0 deletions.
60 changes: 60 additions & 0 deletions consultation-analyser/.github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Check code

env:
DOCKER_BUILDKIT: 1

on:
push:
branches:
- 'main'
- 'feature/**'
- 'bugfix/**'
- 'hotfix/**'
- 'develop'

jobs:
check_web:
name: Check Python

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Set up Python 3.9
uses: actions/setup-python@v3
with:
python-version: 3.9

- name: Install dependencies
run: |
python3 -m pip install --upgrade pip setuptools
pip install -r requirements-dev.lock
- name: Run Python code checks
run: |
make check-python-code
check_migrations:
name: Check migrations

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- run: |
docker-compose build web
docker-compose run web python manage.py makemigrations --check
run_tests:
name: Run tests

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Run tests
run: |
make test
3 changes: 3 additions & 0 deletions consultation-analyser/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.pyc
/staticfiles/
db.sqlite3
38 changes: 38 additions & 0 deletions consultation-analyser/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
include envs/web

define _update_requirements
docker-compose run requirements bash -c "pip install -U pip setuptools && pip install -U -r /app/$(1).txt && pip freeze > /app/$(1).lock"
endef

.PHONY: update-requirements
update-requirements:
$(call _update_requirements,requirements)
$(call _update_requirements,requirements-dev)

.PHONY: reset-db
reset-db:
docker-compose up --detach ${POSTGRES_HOST}
docker-compose run ${POSTGRES_HOST} dropdb -U ${POSTGRES_USER} -h ${POSTGRES_HOST} ${POSTGRES_DB}
docker-compose run ${POSTGRES_HOST} createdb -U ${POSTGRES_USER} -h ${POSTGRES_HOST} ${POSTGRES_DB}
docker-compose kill

# -------------------------------------- Code Style -------------------------------------

.PHONY: check-python-code
check-python-code:
isort --check .
black --check .
flake8
bandit -ll -r consultation_analyser

.PHONY: check-migrations
check-migrations:
docker-compose build web
docker-compose run web python manage.py migrate
docker-compose run web python manage.py makemigrations --check

.PHONY: test
test:
docker-compose down
docker-compose build tests-consultation_analyser consultation_analyser-test-db && docker-compose run --rm tests-consultation_analyser
docker-compose down
1 change: 1 addition & 0 deletions consultation-analyser/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: python manage.py migrate && waitress-serve --port=$PORT consultation_analyser.wsgi:application
31 changes: 31 additions & 0 deletions consultation-analyser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Using Docker

1. [Install Docker](https://docs.docker.com/get-docker/) on your machine
2. `docker-compose up --build --force-recreate web`
3. It's now available at: http://localhost:8000/

Migrations are run automatically at startup, and suppliers are added automatically at startup


## Running tests

make test


## Checking code

make check-python-code


## How to access the admin

Access to the admin is authenticated via username & password and TOTP and authorized for `staff` users.

If you are the first person to get access in any given environment, e.g. your own local env or
a new AWS test env then

1. make yourself a superuser `docker compose run web python manage.py assign_superuser_status --email [email protected] --pwd y0urP4ssw0rd`, you dont need to set the password if you already have one.
2. copy the link generated by the step above and open it on your phone to create a new TOTP account
3. log in to the admin localhost/admin with your email, password and TOTP code generated on your phone/other device.

Otherwise speak to an existing admin to edit your account.
Empty file.
16 changes: 16 additions & 0 deletions consultation-analyser/consultation_analyser/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
ASGI config for people_survey project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""

import os

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "consultation_analyser.settings")

application = get_asgi_application()
Empty file.
14 changes: 14 additions & 0 deletions consultation-analyser/consultation_analyser/consultations/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.contrib import admin
from django_otp.admin import OTPAdminSite

from . import models


class OTPAdmin(OTPAdminSite):
pass


admin_site = OTPAdmin(name="OTPAdmin")


admin.site.register(models.User)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class ConsultationsConfig(AppConfig): # TODO: It's likely you'll have to fix this class name
default_auto_field = "django.db.models.BigAutoField"
name = "consultation_analyser.consultations"
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
BUSINESS_SPECIFIC_WORDS = [
"one big thing",
"whitehall",
"civil service",
"home office",
"cabinet office",
"downing street",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import furl
from django.conf import settings
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone

from consultation_analyser.consultations import models


def _strip_microseconds(dt):
if not dt:
return None
return dt.replace(microsecond=0, tzinfo=None)


class EmailVerifyTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user, timestamp):
login_timestamp = _strip_microseconds(user.last_login)
token_timestamp = _strip_microseconds(user.last_token_sent_at)
return f"{user.id}{timestamp}{login_timestamp}{user.email}{token_timestamp}"


EMAIL_VERIFY_TOKEN_GENERATOR = EmailVerifyTokenGenerator()
PASSWORD_RESET_TOKEN_GENERATOR = PasswordResetTokenGenerator()


EMAIL_MAPPING = {
"email-verification": {
"subject": "Confirm your email address",
"template_name": "email/verification.txt",
"url_name": "verify-email",
"token_generator": EMAIL_VERIFY_TOKEN_GENERATOR,
},
"email-register": {
"subject": "Confirm your email address",
"template_name": "email/verification.txt",
"url_name": "verify-email-register",
"token_generator": EMAIL_VERIFY_TOKEN_GENERATOR,
},
}


def _make_token_url(user, token_type):
token_generator = EMAIL_MAPPING[token_type]["token_generator"]
user.last_token_sent_at = timezone.now()
user.save()
token = token_generator.make_token(user)
base_url = settings.BASE_URL
url_path = reverse(EMAIL_MAPPING[token_type]["url_name"])
url = str(furl.furl(url=base_url, path=url_path, query_params={"code": token, "user_id": str(user.id)}))
return url


def _send_token_email(user, token_type):
url = _make_token_url(user, token_type)
context = dict(user=user, url=url, contact_address=settings.CONTACT_EMAIL)
body = render_to_string(EMAIL_MAPPING[token_type]["template_name"], context)
response = send_mail(
subject=EMAIL_MAPPING[token_type]["subject"],
message=body,
from_email=settings.FROM_EMAIL,
recipient_list=[user.email],
)
return response


def _send_normal_email(subject, template_name, to_address, context):
body = render_to_string(template_name, context)
response = send_mail(
subject=subject,
message=body,
from_email=settings.FROM_EMAIL,
recipient_list=[to_address],
)
return response


def send_password_reset_email(user):
return _send_token_email(user, "password-reset")


def send_invite_email(user):
user.invited_at = timezone.now()
user.save()
return _send_token_email(user, "invite-user")


def send_verification_email(user):
return _send_token_email(user, "email-verification")


def send_register_email(user):
return _send_token_email(user, "email-register")


def send_account_already_exists_email(user):
data = EMAIL_MAPPING["account-already-exists"]
base_url = settings.BASE_URL
reset_url = furl.furl(url=base_url)
reset_url.path.add(data["url_name"])
reset_url = str(reset_url)
context = {"contact_address": settings.CONTACT_EMAIL, "url": base_url, "reset_link": reset_url}
response = _send_normal_email(
subject=data["subject"],
template_name=data["template_name"],
to_address=user.email,
context=context,
)
return response


def verify_token(user_id, token, token_type):
user = models.User.objects.get(id=user_id)
result = EMAIL_MAPPING[token_type]["token_generator"].check_token(user, token)
return result
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
Views for info pages like privacy notice, accessibility statement, etc.
These shouldn't contain sensitive data and don't require login.
"""

from django.shortcuts import render
from django.views.decorators.http import require_http_methods


@require_http_methods(["GET"])
def privacy_notice_view(request):
return render(request, "privacy-notice.html", {})


@require_http_methods(["GET"])
def support_view(request):
return render(request, "support.html", {})


@require_http_methods(["GET"])
def accessibility_statement_view(request):
return render(request, "accessibility-statement.html", {})
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from django.core.management import BaseCommand
from django_otp.plugins.otp_totp.models import TOTPDevice

from consultation_analyser.consultations.models import User


class Command(BaseCommand):
help = """This should be run once per environment to set the initial superuser.
Thereafter the superuser should assign new staff users via the admin and send
them the link to the Authenticator.
Once run this command will return the link to a Time-One-Time-Pass that the
superuser should use to enable login to the admin portal."""

def add_arguments(self, parser):
parser.add_argument("-e", "--email", type=str, help="user's email", required=True)
parser.add_argument("-p", "--password", type=str, help="user's new password")

def handle(self, *args, **kwargs):
email = kwargs["email"]
password = kwargs["password"]

user, _ = User.objects.get_or_create(email=email)

user.is_superuser = True
user.is_staff = True
if password:
user.set_password(password)

user.save()
user.refresh_from_db()

if not user.password:
self.stderr.write(self.style.ERROR(f"A password must be set for '{email}'."))
return

device, _ = TOTPDevice.objects.get_or_create(user=user, confirmed=True, tolerance=0)
self.stdout.write(device.config_url)
return
Loading

0 comments on commit 20fbb97

Please sign in to comment.