diff --git a/consultation-analyser/.github/workflows/checks.yml b/consultation-analyser/.github/workflows/checks.yml new file mode 100644 index 00000000..35d7b5f7 --- /dev/null +++ b/consultation-analyser/.github/workflows/checks.yml @@ -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 \ No newline at end of file diff --git a/consultation-analyser/.gitignore b/consultation-analyser/.gitignore new file mode 100644 index 00000000..05f3393c --- /dev/null +++ b/consultation-analyser/.gitignore @@ -0,0 +1,3 @@ +*.pyc +/staticfiles/ +db.sqlite3 diff --git a/consultation-analyser/Makefile b/consultation-analyser/Makefile new file mode 100644 index 00000000..5271a55f --- /dev/null +++ b/consultation-analyser/Makefile @@ -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 \ No newline at end of file diff --git a/consultation-analyser/Procfile b/consultation-analyser/Procfile new file mode 100644 index 00000000..71abdf58 --- /dev/null +++ b/consultation-analyser/Procfile @@ -0,0 +1 @@ +web: python manage.py migrate && waitress-serve --port=$PORT consultation_analyser.wsgi:application diff --git a/consultation-analyser/README.md b/consultation-analyser/README.md new file mode 100644 index 00000000..0b98a6e9 --- /dev/null +++ b/consultation-analyser/README.md @@ -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 your-email-address@cabinetoffice.gov.uk --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. diff --git a/consultation-analyser/consultation_analyser/__init__.py b/consultation-analyser/consultation_analyser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/consultation-analyser/consultation_analyser/asgi.py b/consultation-analyser/consultation_analyser/asgi.py new file mode 100644 index 00000000..36f41b08 --- /dev/null +++ b/consultation-analyser/consultation_analyser/asgi.py @@ -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() diff --git a/consultation-analyser/consultation_analyser/consultations/__init__.py b/consultation-analyser/consultation_analyser/consultations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/consultation-analyser/consultation_analyser/consultations/admin.py b/consultation-analyser/consultation_analyser/consultations/admin.py new file mode 100644 index 00000000..b559fbf9 --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/admin.py @@ -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) diff --git a/consultation-analyser/consultation_analyser/consultations/apps.py b/consultation-analyser/consultation_analyser/consultations/apps.py new file mode 100644 index 00000000..452fd16b --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/apps.py @@ -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" diff --git a/consultation-analyser/consultation_analyser/consultations/constants.py b/consultation-analyser/consultation_analyser/consultations/constants.py new file mode 100644 index 00000000..a39a87cc --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/constants.py @@ -0,0 +1,8 @@ +BUSINESS_SPECIFIC_WORDS = [ + "one big thing", + "whitehall", + "civil service", + "home office", + "cabinet office", + "downing street", +] diff --git a/consultation-analyser/consultation_analyser/consultations/email_handler.py b/consultation-analyser/consultation_analyser/consultations/email_handler.py new file mode 100644 index 00000000..db86a56b --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/email_handler.py @@ -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 diff --git a/consultation-analyser/consultation_analyser/consultations/info_views.py b/consultation-analyser/consultation_analyser/consultations/info_views.py new file mode 100644 index 00000000..f2f553c0 --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/info_views.py @@ -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", {}) diff --git a/consultation-analyser/consultation_analyser/consultations/management/commands/assign_superuser_status.py b/consultation-analyser/consultation_analyser/consultations/management/commands/assign_superuser_status.py new file mode 100644 index 00000000..15b62688 --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/management/commands/assign_superuser_status.py @@ -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 diff --git a/consultation-analyser/consultation_analyser/consultations/migrations/0001_initial.py b/consultation-analyser/consultation_analyser/consultations/migrations/0001_initial.py new file mode 100644 index 00000000..2ca06ea5 --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.21 on 2023-09-26 12:10 + +from django.db import migrations, models +import django.utils.timezone +import django_use_email_as_username.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', django_use_email_as_username.models.BaseUserManager()), + ], + ), + ] diff --git a/consultation-analyser/consultation_analyser/consultations/migrations/__init__.py b/consultation-analyser/consultation_analyser/consultations/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/consultation-analyser/consultation_analyser/consultations/models.py b/consultation-analyser/consultation_analyser/consultations/models.py new file mode 100644 index 00000000..c76ba2f3 --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/models.py @@ -0,0 +1,28 @@ +import uuid + +from django.db import models +from django_use_email_as_username.models import BaseUser, BaseUserManager + + +class UUIDPrimaryKeyBase(models.Model): + class Meta: + abstract = True + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + +class TimeStampedModel(models.Model): + created_at = models.DateTimeField(editable=False, auto_now_add=True) + modified_at = models.DateTimeField(editable=False, auto_now=True) + + class Meta: + abstract = True + + +class User(BaseUser, UUIDPrimaryKeyBase): + objects = BaseUserManager() + username = None + + def save(self, *args, **kwargs): + self.email = self.email.lower() + super().save(*args, **kwargs) diff --git a/consultation-analyser/consultation_analyser/consultations/utils.py b/consultation-analyser/consultation_analyser/consultations/utils.py new file mode 100644 index 00000000..2990f4ba --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/utils.py @@ -0,0 +1,77 @@ +import enum +import functools +import inspect +import types + + +class ChoicesMeta(enum.EnumMeta): + """A metaclass for creating a enum choices.""" + + def __new__(metacls, classname, bases, classdict, **kwds): + labels = [] + for key in classdict._member_names: + value = classdict[key] + if isinstance(value, (list, tuple)) and len(value) > 1 and isinstance(value[-1], str): + value, label = value + elif hasattr(value, "name"): + label = str(value.name) + else: + label = value + value = key + labels.append(label) + # Use dict.__setitem__() to suppress defenses against double + # assignment in enum's classdict. + dict.__setitem__(classdict, key, value) + cls = super().__new__(metacls, classname, bases, classdict, **kwds) + for member, label in zip(cls.__members__.values(), labels): + member._label_ = label + return enum.unique(cls) + + def __contains__(cls, member): + if not isinstance(member, enum.Enum): + # Allow non-enums to match against member values. + return any(x.value == member for x in cls) + return super().__contains__(member) + + @property + def names(cls): + return tuple(member.name for member in cls) + + @property + def choices(cls): + return tuple((member.name, member.label) for member in cls) + + @property + def labels(cls): + return tuple(label for _, label in cls.choices) + + @property + def values(cls): + return tuple(value for value, _ in cls.choices) + + @property + def options(cls): + return tuple({"value": value, "text": text} for value, text in cls.choices) + + @property + def mapping(cls): + return dict(tuple(cls.choices)) + + +class Choices(enum.Enum, metaclass=ChoicesMeta): + """Class for creating enumerated choices.""" + + @types.DynamicClassAttribute + def label(self): + return self._label_ + + def __repr__(self): + return f"{self.__class__.__qualname__}.{self._name_}" + + def __eq__(self, other): + if self.__class__ is other.__class__: + return super().__eq__(other) + return self.value == other + + def __hash__(self): + return hash(self._name_) \ No newline at end of file diff --git a/consultation-analyser/consultation_analyser/consultations/views.py b/consultation-analyser/consultation_analyser/consultations/views.py new file mode 100644 index 00000000..6cbbcbb8 --- /dev/null +++ b/consultation-analyser/consultation_analyser/consultations/views.py @@ -0,0 +1,23 @@ +from django.conf import settings +from django.shortcuts import render, redirect +from django.views.decorators.http import require_http_methods + +from . import models + + +@require_http_methods(["GET"]) +def index_view(request): + return render( + request, + template_name="index.html", + context={"request": request}, + ) + + +@require_http_methods(["GET"]) +def homepage_view(request): + return render( + request, + template_name="homepage.html", + context={"request": request}, + ) diff --git a/consultation-analyser/consultation_analyser/custom_password_validators.py b/consultation-analyser/consultation_analyser/custom_password_validators.py new file mode 100644 index 00000000..0e7a5740 --- /dev/null +++ b/consultation-analyser/consultation_analyser/custom_password_validators.py @@ -0,0 +1,47 @@ +import re +import string + +from django.core.exceptions import ValidationError + +from consultation_analyser.consultations.constants import BUSINESS_SPECIFIC_WORDS + + +class SpecialCharacterValidator: + msg = "The password must contain at least one special character." + + def validate(self, password, user=None): + special_characters = string.punctuation + + if not any(char in special_characters for char in password): + raise ValidationError(self.msg) + + def get_help_text(self): + return self.msg + + +class LowercaseUppercaseValidator: + msg = "The password must contain at least one lowercase character and one uppercase character." + + def validate(self, password, user=None): + contains_lowercase = any(char.islower() for char in password) + contains_uppercase = any(char.isupper() for char in password) + + if (not contains_lowercase) or (not contains_uppercase): + raise ValidationError(self.msg) + + def get_help_text(self): + return self.msg + + +class BusinessPhraseSimilarityValidator: + msg = "The password should not contain business specific words." + + def validate(self, password, user=None): + password_lower = password.lower() + for phrase in BUSINESS_SPECIFIC_WORDS: + phrase_no_space = phrase.replace(" ", "") + phrase_underscore = phrase.replace(" ", "_") + phrase_dash = phrase.replace(" ", "-") + search_phrase = "|".join([phrase_no_space, phrase_underscore, phrase_dash]) + if re.search(search_phrase, password_lower): + raise ValidationError(self.msg) diff --git a/consultation-analyser/consultation_analyser/jinja2.py b/consultation-analyser/consultation_analyser/jinja2.py new file mode 100644 index 00000000..3e2e48e5 --- /dev/null +++ b/consultation-analyser/consultation_analyser/jinja2.py @@ -0,0 +1,64 @@ +import datetime + +import humanize + +import jinja2 +from django.templatetags.static import static +from django.urls import reverse +from markdown_it import MarkdownIt + +# `js-default` setting required to sanitize inputs +# https://markdown-it-py.readthedocs.io/en/latest/security.html +markdown_converter = MarkdownIt("js-default") + + +def url(path, *args, **kwargs): + assert not (args and kwargs) + return reverse(path, args=args, kwargs=kwargs) + + +def markdown(text, cls=None): + """ + Converts the given text into markdown. + The `replace` statement replaces the outer

tag with one that contains the given class, otherwise the markdown + ends up double wrapped with

tags. + Args: + text: The text to convert to markdown + cls (optional): The class to apply to the outermost

tag surrounding the markdown + + Returns: + Text converted to markdown + """ + html = markdown_converter.render(text).strip() + html = html.replace("

", f'

', 1).replace("

", "", 1) + return html + + +def humanize_timedelta(minutes=0, hours_limit=200, too_large_msg=""): + if minutes > (hours_limit * 60): + if not too_large_msg: + return f"More than {hours_limit} hours" + else: + return too_large_msg + else: + delta = datetime.timedelta(minutes=minutes) + return humanize.precisedelta(delta, minimum_unit="minutes") + + +def environment(**options): + extra_options = dict() + env = jinja2.Environment( # nosec B701 + **{ + "autoescape": True, + **options, + **extra_options, + } + ) + env.globals.update( + { + "static": static, + "url": url, + "humanize_timedelta": humanize_timedelta, + } + ) + return env diff --git a/consultation-analyser/consultation_analyser/settings.py b/consultation-analyser/consultation_analyser/settings.py new file mode 100644 index 00000000..dc940cdd --- /dev/null +++ b/consultation-analyser/consultation_analyser/settings.py @@ -0,0 +1,239 @@ +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + +from .settings_base import ( + BASE_DIR, + SECRET_KEY, + STATIC_ROOT, + STATIC_URL, + STATICFILES_DIRS, + env, +) + +SECRET_KEY = SECRET_KEY +STATIC_URL = STATIC_URL +STATICFILES_DIRS = STATICFILES_DIRS +STATIC_ROOT = STATIC_ROOT + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env.bool("DEBUG", default=False) + +VCAP_APPLICATION = env.json("VCAP_APPLICATION", default={}) +BASE_URL = env.str("BASE_URL") + +# Add AWS URLS to ALLOWED_HOSTS once known +ALLOWED_HOSTS = [ + "localhost", + "127.0.0.1", +] + +# CSRF settings +CSRF_COOKIE_HTTPONLY = True +CSRF_TRUSTED_ORIGINS = [ + # Add your dev and prod urls here, without the protocol +] + +# Application definition + +INSTALLED_APPS = [ + "consultation_analyser.consultations", + "allauth", + "allauth.account", + "allauth.socialaccount", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.sites", + "django.contrib.staticfiles", + "django_otp", + "django_otp.plugins.otp_totp", +] + +CORS_MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "csp.middleware.CSPMiddleware", + "django_permissions_policy.PermissionsPolicyMiddleware", + "django_permissions_policy.PermissionsPolicyMiddleware", + "allauth.account.middleware.AccountMiddleware", +] + +if DEBUG: + MIDDLEWARE = MIDDLEWARE + CORS_MIDDLEWARE + +ROOT_URLCONF = "consultation_analyser.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [ + BASE_DIR / "consultation_analyser" / "templates", + ], + "OPTIONS": {"environment": "consultation_analyser.jinja2.environment"}, + }, + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + BASE_DIR / "consultation_analyser" / "templates" / "allauth", + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "consultation_analyser.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env.str("POSTGRES_DB"), + "USER": env.str("POSTGRES_USER"), + "PASSWORD": env.str("POSTGRES_PASSWORD"), + "HOST": env.str("POSTGRES_HOST"), + "PORT": env.str("POSTGRES_PORT"), + **{"ATOMIC_REQUESTS": True}, + } +} + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", +] + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": { + "min_length": 10, + }, + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, + { + "NAME": "consultation_analyser.custom_password_validators.SpecialCharacterValidator", + }, + { + "NAME": "consultation_analyser.custom_password_validators.LowercaseUppercaseValidator", + }, + { + "NAME": "consultation_analyser.custom_password_validators.BusinessPhraseSimilarityValidator", + }, +] + +if not DEBUG: + SENTRY_DSN = env.str("SENTRY_DSN", default="") + SENTRY_ENVIRONMENT = env.str("SENTRY_ENVIRONMENT", default="") + + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[ + DjangoIntegration(), + ], + environment=SENTRY_ENVIRONMENT, + send_default_pii=False, + traces_sample_rate=1.0, + profiles_sample_rate=0.0, + ) + + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "consultations.User" + +ACCOUNT_AUTHENTICATION_METHOD = "email" +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_USERNAME_REQUIRED = False +SITE_ID = 1 +ACCOUNT_EMAIL_VERIFICATION = "none" +LOGIN_REDIRECT_URL = "homepage" + +if not DEBUG: + SECURE_HSTS_SECONDS = 2 * 365 * 24 * 60 * 60 # Mozilla's guidance max-age 2 years + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + + +# Email + +EMAIL_BACKEND_TYPE = env.str("EMAIL_BACKEND_TYPE") + +if EMAIL_BACKEND_TYPE == "FILE": + EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" + EMAIL_FILE_PATH = env.str("EMAIL_FILE_PATH") +elif EMAIL_BACKEND_TYPE == "CONSOLE": + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +elif EMAIL_BACKEND_TYPE == "GOVUKNOTIFY": + EMAIL_BACKEND = "django_gov_notify.backends.NotifyEmailBackend" + GOVUK_NOTIFY_API_KEY = env.str("GOVUK_NOTIFY_API_KEY") + GOVUK_NOTIFY_PLAIN_EMAIL_TEMPLATE_ID = env.str("GOVUK_NOTIFY_PLAIN_EMAIL_TEMPLATE_ID") +else: + if EMAIL_BACKEND_TYPE not in ("FILE", "CONSOLE", "GOVUKNOTIFY"): + raise Exception(f"Unknown EMAIL_BACKEND_TYPE of {EMAIL_BACKEND_TYPE}") + +if not DEBUG: + SESSION_COOKIE_SECURE = True + SESSION_EXPIRE_AT_BROWSER_CLOSE = False + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_AGE = 60 * 60 * 24 * 120 # 120 days + SESSION_COOKIE_SAMESITE = "Strict" + +PERMISSIONS_POLICY = { + "accelerometer": [], + "autoplay": [], + "camera": [], + "display-capture": [], + "encrypted-media": [], + "fullscreen": [], + "gamepad": [], + "geolocation": [], + "gyroscope": [], + "microphone": [], + "midi": [], + "payment": [], +} + + +CSP_DEFAULT_SRC = ("'self'",) +# SHA of the location of the stylesheet (main.css) + +CSP_STYLE_SRC = ( + "'self'", +) + +OTP_TOTP_ISSUER = "" # TODO: Add issuer name +OTP_TOTP_AUTOCONF = True +OTP_TOTP_KEY_LENGTH = 16 +OTP_TOTP_THROTTLE_FACTOR = 1.0 + +CSRF_COOKIE_HTTPONLY = True diff --git a/consultation-analyser/consultation_analyser/settings_base.py b/consultation-analyser/consultation_analyser/settings_base.py new file mode 100644 index 00000000..65963548 --- /dev/null +++ b/consultation-analyser/consultation_analyser/settings_base.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import environ + +env = environ.Env() + + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = env.str("DJANGO_SECRET_KEY") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.contenttypes", + "django.contrib.staticfiles", +] + +STATIC_URL = "/static/" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" diff --git a/consultation-analyser/consultation_analyser/templates/accessibility-statement.html b/consultation-analyser/consultation_analyser/templates/accessibility-statement.html new file mode 100644 index 00000000..bdc641c1 --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/accessibility-statement.html @@ -0,0 +1,45 @@ +{% import "macros.html" as macros %} +{% extends "base_generic_gov.html" %} + +{% block title %} + Accessibility statement - <SYSTEM_NAME> - GOV.UK +{% endblock %} + +{% block content %} +

Accessibility statement for One Big Thing Platform

+
+

This accessibility statement applies to - .

+

This website is run by the Cabinet Office. We want as many people as possible to be able to use this website. For example, that means you should be able to:

+ +

We've also made the website text as simple as possible to understand.

+

AbilityNet has advice on making your device easier to use if you have a disability.

+

Feedback and contact information

+ If you need information on this website in a different format like accessible PDF, large print, easy read, audio recording or braille: + +

We'll consider your request and get back to you in 14 days.

+ + +

Reporting accessibility problems with this website

+

We're always looking to improve the accessibility of this website. If you find any problems not listed on this page or think we're not meeting accessibility requirements, contact the team at obt-platform-support@cabinetoffice.gov.uk.

+

Enforcement procedure

+

The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 (the "accessibility regulations"). If you're not happy with how we respond to your complaint, contact the Equality Advisory and Support Service (EASS).

+

Technical information about this website's accessibility

+

Cabinet Office is committed to making its website accessible, in accordance with the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018.

+

Compliance status

+

This website is fully compliant with the Web Content Accessibility Guidelines version 2.1 AA standard.

+

Preparation of this accessibility statement

+

This statement was prepared on . It was last reviewed on .

+

This website was last tested on . The test was carried out by Digital Accessibility Centre.

+ + + +
+{% endblock %} diff --git a/consultation-analyser/consultation_analyser/templates/allauth/account/base.html b/consultation-analyser/consultation_analyser/templates/allauth/account/base.html new file mode 100644 index 00000000..14f4f262 --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/allauth/account/base.html @@ -0,0 +1,42 @@ +{% load static %} + + + + {% block head_title %}{% endblock %} + {% block extra_head %} + {% endblock %} + + + + {% block body %} + + {% if messages %} +
+ Messages: + +
+ {% endif %} + +
+ Menu: + +
+ {% block content %} + {% endblock %} + {% endblock %} + {% block extra_body %} + {% endblock %} + + diff --git a/consultation-analyser/consultation_analyser/templates/email/verification.txt b/consultation-analyser/consultation_analyser/templates/email/verification.txt new file mode 100644 index 00000000..c4ece62d --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/email/verification.txt @@ -0,0 +1,10 @@ +Hi, + +You can use the following link to sign in to your account: +{{url|safe}} + +You can only use this link once. If you do not use it within 1 hour, it will expire. + +Regards + + diff --git a/consultation-analyser/consultation_analyser/templates/homepage.html b/consultation-analyser/consultation_analyser/templates/homepage.html new file mode 100644 index 00000000..d62a4c40 --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/homepage.html @@ -0,0 +1 @@ +

Home Page

diff --git a/consultation-analyser/consultation_analyser/templates/index.html b/consultation-analyser/consultation_analyser/templates/index.html new file mode 100644 index 00000000..b4bb7d68 --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/index.html @@ -0,0 +1 @@ +

Index Page

diff --git a/consultation-analyser/consultation_analyser/templates/privacy-notice.html b/consultation-analyser/consultation_analyser/templates/privacy-notice.html new file mode 100644 index 00000000..12f8adce --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/privacy-notice.html @@ -0,0 +1,68 @@ +{% import "macros.html" as macros %} +{% extends "base_generic_gov.html" %} + +{% block title %} + Privacy notice - <SYSTEM_NAME> - GOV.UK +{% endblock %} + + +{% block content %} +

Privacy notice for platform

+
+

This notice sets out how we will use your personal data, and your rights. It is made under Articles 13 and/or 14 of the UK General Data Protection Regulation (UK GDPR).

+ +

Your data

+

Purpose

+

The purpose(s) for which we are processing your personal data is (are):

+
    +
  • enabling login to the platform
  • +
  • emailing you reminders of actions required
  • +
  • information will be aggregated and used for anonymous further analysis
  • +
+ +

The data

+

We will process the following personal data:

+
    +
  • organisational email address
  • +
+ + +

Legal basis of processing

+

The legal basis for processing your personal data is:

+
    +
  • data subject consents
  • +
+ +

Recipients

+

Your personal data will be shared by us with analysts/researchers within the Cabinet Office if you have consented to be contacted further about .

+

As your personal data will be stored on our IT infrastructure it will also be shared with our data processors who provide email, and document management and storage services.

+ +

Retention

+

Your personal data will be retained for 12 months after account inactivity. At that point we will delete your email address, depersonalising the data we hold. If you have provided us consent to contact you, we will retain your email until and continue sharing it with evaluators so they can conduct research on the long term impact of .

+ +

Your rights

+

You have the right to request information about how your personal data are processed, and to request a copy of that personal data.

+

You have the right to request that any inaccuracies in your personal data are rectified without delay.

+

You have the right to request that any incomplete personal data are completed, including by means of a supplementary statement.

+

You have the right to request that your personal data are erased if there is no longer a justification for them to be processed.

+

You have the right in certain circumstances (for example, where accuracy is contested) to request that the processing of your personal data is restricted.

+

You have the right to object to the processing of your personal data where it is processed for direct marketing purposes.

+

You have the right to withdraw consent to the processing of your personal data at any time.

+ +

International transfers

+

As your personal data is stored on our IT infrastructure, and shared with our data processors, it may be transferred and stored securely outside the UK. Where that is the case it will be subject to equivalent legal protection through an adequacy decision or reliance on Standard Contractual Clauses.

+ +

Complaints

+

If you consider that your personal data has been misused or mishandled, you may make a complaint to the Information Commissioner, who is an independent regulator. The Information Commissioner can be contacted at: Information Commissioner's Office, Wycliffe House, Water Lane, Wilmslow, Cheshire, SK9 5AF, or 0303 123 1113, or icocasework@ico.org.uk. Any complaint to the Information Commissioner is without prejudice to your right to seek redress through the courts.

+ +

Contact details

+

The data controller for your personal data is the Cabinet Office. The contact details for the data controller are: Cabinet Office, 70 Whitehall, London, SW1A 2AS, or 0207 276 1234, or publiccorrespondence@cabinetoffice.gov.uk.

+

The contact details for the data controller's Data Protection Officer are: dpo@cabinetoffice.gov.uk.

+

The Data Protection Officer provides independent advice and monitoring of Cabinet Office's use of personal information.

+ + +

Changes to this notice

+

We may change this privacy notice. When we make changes to this notice, the "last updated" date at the bottom of this page will also change. Any changes to this privacy notice will apply to you and your data immediately. If these changes affect how your personal data is processed, Cabinet Office will take reasonable steps to make sure you know.

+

Last updated:

+
+{% endblock %} diff --git a/consultation-analyser/consultation_analyser/templates/support.html b/consultation-analyser/consultation_analyser/templates/support.html new file mode 100644 index 00000000..6020401e --- /dev/null +++ b/consultation-analyser/consultation_analyser/templates/support.html @@ -0,0 +1,81 @@ +{% import "macros.html" as macros %} +{% extends "base_generic_gov.html" %} + +{% block title %} + Support - <SYSTEM_NAME> - GOV.UK +{% endblock %} + +{% block content %} +

Support

+ Find out about the One Big Thing website, including how to sign in and use your account. +
+

User guide

+ +

You can download the User Guide (PDF, 1.1MB), a step-by-step guide to signing in and using your account.

+ +

Accessing

+

You can access your account by visiting . You'll need to type your work email address in the box provided and select the submit button.

+ +

Signing in

+

If you're having trouble signing in to the platform, check that:

+ + + +

You should check your junk or spam folder for an email from .

+ +

If you're still having trouble, ask your line manager for support. If the user guide doesn't have the answer then you can contact us using the details at the bottom of this page.

+ +

Email address not allowed

+

If you see the error message: "Currently you need a Civil Service email address to register", contact us using the details at the bottom of this page and include the name of your organisation.

+ +

Can’t find my government department

+

If you can’t find the department you belong to when you are signing in for the first time then go to the bottom of the dropdown list and if you are: +

+ + + +

Can’t find my grade

+

If the grades shown do not fit with your role then either choose the one that is equivalent to your role or select ‘Other’.

+ +

Bug or site vulnerability

+

If you've found an issue or a vulnerability, contact us using the details at the bottom of this page including as much information as possible.

+ +

Website not loading

+

If the website is not loading, please close the browser tab and try again later.

+ +

Choosing a different learning level

+

You cannot change the learning level recommended to you, but you can choose to complete a course marked as suitable for any of the learning levels:

+ + + +

These can be accessed from your One Big Thing account under the heading "See e-learning for all learning levels"

+ +

Completing

+

One Big Thing is encouraging all civil servants to complete 7 hours of learning on the topic of data.

+

You should talk to your line manager to decide how much learning you want to complete.

+

You can use the Record Learning page on your One Big Thing account to keep track of the learning you've done.

+

When you've reached your learning target, you'll need to return to your One Big Thing account and select the feedback button at the bottom of the overview page.

+

You'll be asked to complete a survey about your experience of One Big Thing and your data confidence.

+

When you get to the end of the survey you'll have completed One Big Thing.

+ +

Signing into Civil Service Learning:

+

One Big Thing is not part of Civil Service Learning, but your One Big Thing account does include links to Civil Service Learning courses.

+

If you cannot login to Civil Service Learning, you can contact the Civil Service Learning helpdesk on 020 3640 7985 or by email on support@governmentcampus.co.uk.

+

The helpdesk phone service is available Monday to Friday 8:30am to 5:30pm except all UK bank holidays.

+ +
+

Contact us:

+

If you've read the information above but haven't been able to find a solution to your problem, please email us at obt-platform-support@cabinetoffice.gov.uk including as much information as possible.

+ +{% endblock %} diff --git a/consultation-analyser/consultation_analyser/urls.py b/consultation-analyser/consultation_analyser/urls.py new file mode 100644 index 00000000..a2bec1fb --- /dev/null +++ b/consultation-analyser/consultation_analyser/urls.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.urls import include, path + +from consultation_analyser.consultations import views, info_views + +info_urlpatterns = [ + path("privacy-notice/", info_views.privacy_notice_view, name="privacy-notice"), + path("accessibility-statement/", info_views.accessibility_statement_view, name="accessibility-statement"), + path("support/", info_views.support_view, name="support"), +] + +other_urlpatterns = [ + path("", views.index_view, name="index"), + path("home/", views.homepage_view, name="homepage"), + path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), +] + +urlpatterns = info_urlpatterns + other_urlpatterns diff --git a/consultation-analyser/consultation_analyser/wsgi.py b/consultation-analyser/consultation_analyser/wsgi.py new file mode 100644 index 00000000..1b616cf0 --- /dev/null +++ b/consultation-analyser/consultation_analyser/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for people_survey project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "consultation_analyser.settings") + +application = get_wsgi_application() diff --git a/consultation-analyser/docker-compose.yml b/consultation-analyser/docker-compose.yml new file mode 100644 index 00000000..d46e709e --- /dev/null +++ b/consultation-analyser/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3' + +volumes: + local_postgres_data: {} + +services: + web: + build: + context: . + dockerfile: ./docker/web/Dockerfile + depends_on: + - db + env_file: + - ./envs/web + volumes: + - ./:/app/:z + ports: + - "8000:8000" + + db: + image: postgres:13 + volumes: + - local_postgres_data:/var/lib/postgresql/data:Z + env_file: + - ./envs/web + ports: + - "5432" + + requirements: + image: python:3.9-buster + profiles: + - utils + volumes: + - ./:/app/:z + + tests-consultation_analyser: + build: + context: . + dockerfile: ./docker/tests/Dockerfile + image: tests_consultation_analyser + env_file: + - ./envs/tests + profiles: + - testing + depends_on: + - consultation_analyser-test-db + + consultation_analyser-test-db: + image: postgres:13 + container_name: consultation_analyser_test_postgres + env_file: + - ./envs/tests + expose: + - "5432" \ No newline at end of file diff --git a/consultation-analyser/docker/tests/Dockerfile b/consultation-analyser/docker/tests/Dockerfile new file mode 100644 index 00000000..4c4d6e7f --- /dev/null +++ b/consultation-analyser/docker/tests/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.9-buster + +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt update -y && apt install -y postgresql-client + +RUN curl -o /bin/wait-for-db https://github.com/palfrey/wait-for-db/releases/download/v1.2.0/wait-for-db-linux-x86 +RUN chmod +x /bin/wait-for-db + +RUN python3 -m pip install -U pip setuptools wheel + +COPY ./requirements-dev.lock /app/requirements-dev.lock +RUN python3 -m pip install -r /app/requirements-dev.lock --no-cache-dir + +COPY ./docker/tests/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +COPY ./docker/tests/start.sh /start.sh +RUN chmod +x /start.sh + +COPY . /app + +WORKDIR /app/ + +RUN \ + DJANGO_SETTINGS_MODULE=consultation_analyser.settings_base \ + DJANGO_SECRET_KEY="temp" \ + python manage.py collectstatic --no-input + +ENTRYPOINT ["sh","/entrypoint.sh"] +CMD ["sh","/start.sh"] diff --git a/consultation-analyser/docker/tests/entrypoint.sh b/consultation-analyser/docker/tests/entrypoint.sh new file mode 100644 index 00000000..4cfe3feb --- /dev/null +++ b/consultation-analyser/docker/tests/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +wait-for-db --mode postgres --connection-string $DATABASE_URL --timeout 60 --sql-query "select 1;" + +exec "$@" diff --git a/consultation-analyser/docker/tests/start.sh b/consultation-analyser/docker/tests/start.sh new file mode 100644 index 00000000..c6ec610e --- /dev/null +++ b/consultation-analyser/docker/tests/start.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +python manage.py migrate --noinput +echo +echo '----------------------------------------------------------------------' +echo +nosetests -v ./tests --logging-level=ERROR --with-coverage --cover-package=consultation_analyser +pytest -v ./pytest_tests diff --git a/consultation-analyser/docker/web/Dockerfile b/consultation-analyser/docker/web/Dockerfile new file mode 100644 index 00000000..4f576ecf --- /dev/null +++ b/consultation-analyser/docker/web/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.9-buster + +ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 +ENV TZ=UTC + +ARG BASE_URL + +ENV BASE_URL=${BASE_URL} + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN curl -o /bin/wait-for-db https://github.com/palfrey/wait-for-db/releases/download/v1.2.0/wait-for-db-linux-x86 +RUN chmod +x /bin/wait-for-db + +RUN python3 -m pip install -U pip setuptools wheel + +COPY ./requirements.lock /app/requirements.lock +RUN python3 -m pip install -r /app/requirements.lock --no-cache-dir + +COPY ./docker/web/start.sh /start.sh +RUN chmod +x /start.sh + +COPY ./docker/web/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +COPY . /app + +WORKDIR /app/ + +RUN \ + DJANGO_SETTINGS_MODULE=consultation_analyser.settings_base \ + DJANGO_SECRET_KEY="temp" \ + python manage.py collectstatic --no-input + +EXPOSE 8012 + +ENTRYPOINT ["sh","/entrypoint.sh"] +CMD ["sh","/start.sh"] diff --git a/consultation-analyser/docker/web/entrypoint.sh b/consultation-analyser/docker/web/entrypoint.sh new file mode 100644 index 00000000..785eb7bd --- /dev/null +++ b/consultation-analyser/docker/web/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +set -o errexit + +if [ "$DEBUG" = true ] && [ -z "$DATABASE_URL" ] +then + wait-for-db --mode postgres --connection-string "$DATABASE_URL" --timeout 60 --sql-query "select 1;" +fi + +exec "$@" diff --git a/consultation-analyser/docker/web/start.sh b/consultation-analyser/docker/web/start.sh new file mode 100644 index 00000000..2dfe2f5c --- /dev/null +++ b/consultation-analyser/docker/web/start.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +python manage.py migrate --noinput + +echo "Migrations completed" + +echo "Starting app" + +echo "Using '$ENVIRONMENT' environment settings" + +if [ "$ENVIRONMENT" = "LOCAL" ] +then + gunicorn --reload --workers 3 consultation_analyser.wsgi:application +else + gunicorn --workers 3 consultation_analyser.wsgi:application +fi diff --git a/consultation-analyser/envs/tests b/consultation-analyser/envs/tests new file mode 100644 index 00000000..73392424 --- /dev/null +++ b/consultation-analyser/envs/tests @@ -0,0 +1,12 @@ +DJANGO_SETTINGS_MODULE=consultation_analyser.settings +PORT=8000 +DEBUG=True +DJANGO_SECRET_KEY='1n53cur3K3y' +POSTGRES_HOST=consultation_analyser-test-db +POSTGRES_PORT=5432 +POSTGRES_DB=consultation-analyser +POSTGRES_USER=consultation-analyser +POSTGRES_PASSWORD=insecure +PGPASSWORD=insecure +DATABASE_URL="postgres://consultation-analyser:insecure@consultation_analyser-test-db:5432/consultation-analyser" +BASE_URL=http://localhost:8000/ diff --git a/consultation-analyser/envs/web b/consultation-analyser/envs/web new file mode 100644 index 00000000..03101302 --- /dev/null +++ b/consultation-analyser/envs/web @@ -0,0 +1,13 @@ +DJANGO_SETTINGS_MODULE=consultation_analyser.settings +PORT=8000 +DEBUG=True +DJANGO_SECRET_KEY='1n53cur3K3y' +POSTGRES_HOST=db +POSTGRES_PORT=5432 +POSTGRES_DB=consultation-analyser +POSTGRES_USER=consultation-analyser +POSTGRES_PASSWORD=insecure +PGPASSWORD=insecure +DATABASE_URL="postgres://consultation-analyser:insecure@db:5432/consultation-analyser" +EMAIL_BACKEND_TYPE=CONSOLE +ENVIRONMENT=LOCAL diff --git a/consultation-analyser/manage.py b/consultation-analyser/manage.py new file mode 100755 index 00000000..dab71e2e --- /dev/null +++ b/consultation-analyser/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "consultation_analyser.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/consultation-analyser/manifest.yml b/consultation-analyser/manifest.yml new file mode 100644 index 00000000..580479d2 --- /dev/null +++ b/consultation-analyser/manifest.yml @@ -0,0 +1,6 @@ +--- +applications: +- name: consultation-analyser-develop + memory: 512M + buildpacks: + - python_buildpack diff --git a/consultation-analyser/pyproject.toml b/consultation-analyser/pyproject.toml new file mode 100644 index 00000000..eaeffc8b --- /dev/null +++ b/consultation-analyser/pyproject.toml @@ -0,0 +1,10 @@ +[tool.black] +line-length = 120 +target-version = ['py38'] + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true diff --git a/consultation-analyser/pytest_tests/__init__.py b/consultation-analyser/pytest_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/consultation-analyser/requirements-dev.lock b/consultation-analyser/requirements-dev.lock new file mode 100644 index 00000000..4288fc7a --- /dev/null +++ b/consultation-analyser/requirements-dev.lock @@ -0,0 +1,86 @@ +anyio==4.0.0 +asgiref==3.7.2 +bandit==1.7.5 +black==23.9.1 +certifi==2023.7.22 +cffi==1.15.1 +charset-normalizer==3.2.0 +click==8.1.7 +coverage==7.3.1 +cryptography==41.0.4 +cssselect==1.2.0 +defusedxml==0.7.1 +Django==3.2.21 +django-allauth==0.56.1 +django-allow-cidr==0.7.1 +django-cors-headers==4.2.0 +django-csp==3.7 +django-environ==0.11.2 +django-otp==1.2.3 +django-permissions-policy==4.17.0 +django-use-email-as-username==1.4.0 +exceptiongroup==1.1.3 +flake8==6.1.0 +flake8-blind-except==0.2.1 +flake8-isort==6.1.0 +flake8-print==5.0.0 +gitdb==4.0.10 +GitPython==3.1.37 +gunicorn==21.2.0 +h11==0.14.0 +httpcore==0.18.0 +httpx==0.25.0 +humanize==4.7.0 +idna==3.4 +iniconfig==2.0.0 +isort==5.12.0 +Jinja2==3.1.2 +jmespath==1.0.1 +lxml==4.9.3 +markdown-it-py==3.0.0 +MarkupSafe==2.1.3 +marshmallow==3.19.0 +mccabe==0.7.0 +mdurl==0.1.2 +mypy-extensions==1.0.0 +nose==1.3.7 +oauthlib==3.2.2 +packaging==23.1 +parsel==1.8.1 +pathspec==0.11.2 +pbr==5.11.1 +pep8-naming==0.13.3 +Pillow==10.0.1 +platformdirs==3.10.0 +pluggy==1.3.0 +psycopg2-binary==2.9.7 +pycodestyle==2.11.0 +pycparser==2.21 +pyflakes==3.1.0 +Pygments==2.16.1 +PyJWT==2.8.0 +pyotp==2.9.0 +pypng==0.20220715.0 +pytest==7.4.2 +pytest-django==4.5.2 +python3-openid==3.2.0 +pytz==2023.3.post1 +PyYAML==6.0.1 +qrcode==7.4.2 +requests==2.31.0 +requests-oauthlib==1.3.1 +requests-wsgi-adapter==0.4.1 +rich==13.5.3 +sentry-sdk==1.31.0 +smmap==5.0.1 +sniffio==1.3.0 +sqlparse==0.4.4 +stevedore==5.1.0 +testino==0.3.14 +tomli==2.0.1 +typing_extensions==4.8.0 +urllib3==2.0.5 +w3lib==2.1.2 +watchdog==3.0.0 +Werkzeug==2.3.7 +whitenoise==6.5.0 diff --git a/consultation-analyser/requirements-dev.txt b/consultation-analyser/requirements-dev.txt new file mode 100644 index 00000000..014ae6f0 --- /dev/null +++ b/consultation-analyser/requirements-dev.txt @@ -0,0 +1,17 @@ +flake8 +flake8-isort +flake8-print +flake8-blind-except +pep8-naming +black +nose +httpx +testino +bandit +sentry-sdk +coverage +click +pytest +pytest-django + +-r ./requirements.lock diff --git a/consultation-analyser/requirements.lock b/consultation-analyser/requirements.lock new file mode 100644 index 00000000..4f2acdeb --- /dev/null +++ b/consultation-analyser/requirements.lock @@ -0,0 +1,43 @@ +asgiref==3.7.2 +certifi==2023.7.22 +cffi==1.15.1 +charset-normalizer==3.2.0 +cryptography==41.0.4 +defusedxml==0.7.1 +Django==3.2.21 +django-allauth==0.56.1 +django-allow-cidr==0.7.1 +django-cors-headers==4.2.0 +django-csp==3.7 +django-environ==0.11.2 +django-otp==1.2.3 +django-permissions-policy==4.17.0 +django-use-email-as-username==1.4.0 +gunicorn==21.2.0 +humanize==4.7.0 +idna==3.4 +Jinja2==3.1.2 +markdown-it-py==3.0.0 +MarkupSafe==2.1.3 +marshmallow==3.19.0 +mdurl==0.1.2 +oauthlib==3.2.2 +packaging==23.1 +Pillow==10.0.1 +psycopg2-binary==2.9.7 +pycparser==2.21 +PyJWT==2.8.0 +pyotp==2.9.0 +pypng==0.20220715.0 +python3-openid==3.2.0 +pytz==2023.3.post1 +PyYAML==6.0.1 +qrcode==7.4.2 +requests==2.31.0 +requests-oauthlib==1.3.1 +sentry-sdk==1.31.0 +sqlparse==0.4.4 +typing_extensions==4.8.0 +urllib3==2.0.5 +watchdog==3.0.0 +whitenoise==6.5.0 diff --git a/consultation-analyser/requirements.txt b/consultation-analyser/requirements.txt new file mode 100644 index 00000000..ddda3df3 --- /dev/null +++ b/consultation-analyser/requirements.txt @@ -0,0 +1,24 @@ +django<3.3 +django-cors-headers +django-allow-cidr +whitenoise +watchdog[watchmedo] +jinja2 +django-environ +django-allauth +django_use_email_as_username +gunicorn +psycopg2-binary +pyyaml +markdown-it-py +pytz +django-csp +django-permissions-policy +marshmallow~=3.19.0 +humanize~=4.7.0 +sentry-sdk + +# TOTP +pyotp +qrcode[pil] +django-otp diff --git a/consultation-analyser/setup.cfg b/consultation-analyser/setup.cfg new file mode 100644 index 00000000..acd44e04 --- /dev/null +++ b/consultation-analyser/setup.cfg @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 120 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv +extend-ignore = E203, W503, E231, N804, N805, E731, N815 + +[pycodestyle] +max-line-length = 120 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv diff --git a/consultation-analyser/static/allauth.css b/consultation-analyser/static/allauth.css new file mode 100644 index 00000000..1e30d216 --- /dev/null +++ b/consultation-analyser/static/allauth.css @@ -0,0 +1,3 @@ +* { + background-color: red; +} diff --git a/consultation-analyser/tests/__init__.py b/consultation-analyser/tests/__init__.py new file mode 100644 index 00000000..f29858cd --- /dev/null +++ b/consultation-analyser/tests/__init__.py @@ -0,0 +1,6 @@ +import os + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "consultation_analyser.settings") +django.setup() diff --git a/consultation-analyser/tests/utils.py b/consultation-analyser/tests/utils.py new file mode 100644 index 00000000..4a357312 --- /dev/null +++ b/consultation-analyser/tests/utils.py @@ -0,0 +1,22 @@ +import functools + +import httpx +import testino + +import consultation_analyser.wsgi + +TEST_SERVER_URL = "http://consultation-analyser-testserver:8000/" + + +def with_client(func): + @functools.wraps(func) + def _inner(*args, **kwargs): + with httpx.Client(app=consultation_analyser.wsgi.application, base_url=TEST_SERVER_URL) as client: + return func(client, *args, **kwargs) + + return _inner + + +def make_testino_client(): + client = testino.WSGIAgent(consultation_analyser.wsgi.application, TEST_SERVER_URL) + return client