├── app ├── models.py ├── main │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── templates │ ├── main │ │ ├── 429.html │ │ ├── 503.html │ │ ├── 404.html │ │ ├── 500.html │ │ ├── privacy.html │ │ ├── index.html │ │ ├── cookies.html │ │ └── accessibility.html │ └── base.html └── __init__.py ├── tests ├── __init__.py ├── test_init.py └── test_main_routes.py ├── .nvmrc ├── .python-version ├── .flaskenv ├── migrations ├── README ├── script.py.mako ├── alembic.ini └── env.py ├── govuk-frontend-flask.py ├── web ├── src │ ├── js │ │ ├── main.mjs │ │ └── modules │ │ │ ├── govuk-frontend.mjs │ │ │ └── cookie-banner.mjs │ ├── assets │ │ └── manifest.json │ └── scss │ │ └── main.scss ├── .browserslistrc ├── eslint.config.mjs ├── Dockerfile ├── package.json ├── webpack.config.js ├── nginx.conf ├── README.md └── .gitignore ├── requirements_dev.in ├── requirements.in ├── setup.cfg ├── .dockerignore ├── docker-entrypoint.sh ├── .github ├── dependabot.yml ├── workflows │ ├── webpack.yml │ ├── dependency-review.yml │ ├── python-app.yml │ ├── codeql.yml │ └── docker-publish.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── CHANGELOG.md ├── LICENSE ├── Dockerfile ├── config.py ├── requirements.txt ├── CONTRIBUTING.md ├── compose.yml ├── CODE_OF_CONDUCT.md ├── requirements_dev.txt ├── README.md └── .gitignore /app/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/krypton 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=govuk-frontend-flask.py -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /govuk-frontend-flask.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /web/src/js/main.mjs: -------------------------------------------------------------------------------- 1 | import "./modules/cookie-banner.mjs"; 2 | import "./modules/govuk-frontend.mjs"; 3 | -------------------------------------------------------------------------------- /requirements_dev.in: -------------------------------------------------------------------------------- 1 | bandit 2 | black 3 | flake8-bugbear 4 | isort 5 | mypy 6 | pep8-naming 7 | pip-audit 8 | pip-tools 9 | pur 10 | pytest-cov 11 | python-dotenv 12 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | email_validator 2 | flask 3 | flask-limiter[redis] 4 | flask-migrate 5 | flask-sqlalchemy 6 | govuk-frontend-jinja 7 | govuk-frontend-wtf 8 | gunicorn 9 | psycopg2 10 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp: Blueprint = Blueprint("main", __name__, template_folder="../templates/main") 4 | 5 | from app.main import routes # noqa: E402,F401 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=venv 3 | ignore=E203,W503 4 | max-complexity=10 5 | max-line-length=120 6 | 7 | [isort] 8 | profile=black 9 | multi_line_output=3 10 | line_length=120 11 | -------------------------------------------------------------------------------- /web/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.1% in GB and not dead 2 | last 6 Chrome versions 3 | last 6 Firefox versions 4 | last 6 Edge versions 5 | last 2 Samsung versions 6 | Firefox ESR 7 | Safari >= 11 8 | iOS >= 11 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .env 3 | .flaskenv 4 | .git 5 | .github 6 | .mypy_cache 7 | .nvmrc 8 | .python-version 9 | .vscode 10 | **/__pycache__ 11 | **/.gitignore 12 | **/dist 13 | CHANGELOG.md 14 | CODE_OF_CONDUCT.md 15 | compose.yml 16 | CONTRIBUTING.md 17 | env 18 | LICENSE 19 | README.md 20 | requirements_dev.in 21 | requirements_dev.txt 22 | requirements.in 23 | setup.cfg 24 | tests 25 | venv -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | echo "Running DB migrations..." 5 | flask db upgrade 6 | 7 | # Dynamic worker count (default: 2×CPU + 1) 8 | : "${WORKERS:=$(( $(nproc) * 2 + 1 ))}" 9 | : "${BIND_ADDR:=0.0.0.0:5000}" 10 | : "${ACCESS_LOG:=-}" 11 | 12 | echo "Starting Gunicorn on $BIND_ADDR with $WORKERS workers..." 13 | exec gunicorn --bind "$BIND_ADDR" -w "$WORKERS" --access-logfile "$ACCESS_LOG" govuk-frontend-flask:app 14 | -------------------------------------------------------------------------------- /web/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import globals from "globals"; 3 | import js from "@eslint/js"; 4 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 5 | 6 | export default defineConfig([ 7 | { files: ["**/*.{js,mjs,cjs}"] }, 8 | { 9 | files: ["**/*.{js,mjs,cjs}"], 10 | languageOptions: { globals: globals.browser }, 11 | }, 12 | { 13 | files: ["**/*.{js,mjs,cjs}"], 14 | plugins: { js }, 15 | extends: ["js/recommended"], 16 | }, 17 | eslintConfigPrettier, 18 | ]); 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /app/templates/main/429.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- block pageTitle -%}Too many requests – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 4 | 5 | {%- set mainClasses = "govuk-main-wrapper--l" -%} 6 | 7 | {%- block content -%} 8 |
9 |
10 |

Sorry, there is a problem

11 |

There have been too many attempts to access this page.

12 |

Try again later.

13 |
14 |
15 | {%- endblock -%} -------------------------------------------------------------------------------- /app/templates/main/503.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- block pageTitle -%}Sorry, the service is unavailable – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 4 | 5 | {%- set mainClasses = "govuk-main-wrapper--l" -%} 6 | 7 | {%- block content -%} 8 |
9 |
10 |

Sorry, the service is unavailable

11 |

You will be able to use the service from 9am on Monday 19 November 2018.

12 |
13 |
14 | {%- endblock -%} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased](https://github.com///compare/0.1.0...latest) 8 | 9 | ## [0.1.0](https://github.com///releases/tag/0.1.0) - dd/mm/yyyy 10 | 11 | ### Added 12 | 13 | ### Changed 14 | 15 | ### Deprecated 16 | 17 | ### Removed 18 | 19 | ### Fixed 20 | 21 | ### Security 22 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/templates/main/404.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- block pageTitle -%}Page not found – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 4 | 5 | {%- set mainClasses = "govuk-main-wrapper--l" -%} 6 | 7 | {%- block content -%} 8 |
9 |
10 |

Page not found

11 |

If you typed the web address, check it is correct.

12 |

If you pasted the web address, check you copied the entire address.

13 |
14 |
15 | {%- endblock -%} -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:krypton-alpine AS builder 2 | 3 | WORKDIR /web 4 | 5 | COPY .browserslistrc eslint.config.mjs package*.json webpack.config.js ./ 6 | COPY src src 7 | 8 | RUN npm install && \ 9 | npm run build 10 | 11 | FROM nginx:stable-alpine 12 | 13 | RUN apk update && apk add --no-cache openssl && \ 14 | mkdir /etc/nginx/ssl && \ 15 | openssl req -x509 -noenc -newkey rsa:2048 -keyout /etc/nginx/ssl/key.pem -out /etc/nginx/ssl/req.pem -days 90 -subj "/C=GB/ST=Devon/L=Plymouth/O=HM Land Registry/OU=Digital/CN=localhost" 16 | 17 | COPY nginx.conf /etc/nginx/conf.d 18 | 19 | COPY --from=builder /web/dist /web/static -------------------------------------------------------------------------------- /app/templates/main/500.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- block pageTitle -%}Sorry, there is a problem with the service – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 4 | 5 | {%- set mainClasses = "govuk-main-wrapper--l" -%} 6 | 7 | {%- block content -%} 8 |
9 |
10 |

Sorry, there is a problem with the service

11 |

Try again later.

12 |

We saved your answers. They will be available for 30 days.

13 |
14 |
15 | {%- endblock -%} -------------------------------------------------------------------------------- /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS with Webpack 2 | 3 | on: 4 | push: 5 | branches: ["latest"] 6 | pull_request: 7 | branches: ["latest"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x, 22.x, 24.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Build 26 | run: | 27 | cd web 28 | npm install 29 | npx webpack 30 | -------------------------------------------------------------------------------- /app/templates/main/privacy.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- from 'govuk_frontend_jinja/components/back-link/macro.html' import govukBackLink -%} 4 | 5 | {%- block pageTitle -%}Privacy notice – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 6 | 7 | {%- block beforeContent -%} 8 | {{ super() }} 9 | {{ govukBackLink({ 10 | 'text': "Back", 11 | 'href': url_for('main.index') 12 | }) }} 13 | {%- endblock -%} 14 | 15 | {%- block content -%} 16 |
17 |
18 | {{ super() }} 19 |

Privacy notice

20 |
21 |
22 | {%- endblock -%} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /web/src/js/modules/govuk-frontend.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | createAll, 3 | // Accordion, 4 | Button, 5 | // CharacterCount, 6 | // Checkboxes, 7 | ErrorSummary, 8 | // ExitThisPage, 9 | // FileUpload, 10 | // Header, 11 | NotificationBanner, 12 | // PasswordInput, 13 | Radios, 14 | ServiceNavigation, 15 | SkipLink, 16 | // Tabs, 17 | } from "govuk-frontend"; 18 | 19 | // createAll(Accordion); 20 | createAll(Button); 21 | // createAll(CharacterCount); 22 | // createAll(Checkboxes); 23 | createAll(ErrorSummary); 24 | // createAll(ExitThisPage); 25 | // createAll(FileUpload); 26 | // createAll(Header); 27 | createAll(NotificationBanner); 28 | // createAll(PasswordInput); 29 | createAll(Radios); 30 | createAll(ServiceNavigation); 31 | createAll(SkipLink); 32 | // createAll(Tabs); 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /app/main/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm # type: ignore 2 | from govuk_frontend_wtf.wtforms_widgets import GovRadioInput, GovSubmitInput # type: ignore 3 | from wtforms.fields import RadioField, SubmitField # type: ignore 4 | from wtforms.validators import InputRequired # type: ignore 5 | 6 | 7 | class CookiesForm(FlaskForm): 8 | """Form for managing cookie preferences.""" 9 | 10 | functional: RadioField = RadioField( 11 | "Do you want to accept functional cookies?", 12 | widget=GovRadioInput(), 13 | validators=[InputRequired(message="Select yes if you want to accept functional cookies")], 14 | choices=[("no", "No"), ("yes", "Yes")], 15 | default="no", 16 | ) 17 | analytics: RadioField = RadioField( 18 | "Do you want to accept analytics cookies?", 19 | widget=GovRadioInput(), 20 | validators=[InputRequired(message="Select yes if you want to accept analytics cookies")], 21 | choices=[("no", "No"), ("yes", "Yes")], 22 | default="no", 23 | ) 24 | save: SubmitField = SubmitField("Save cookie settings", widget=GovSubmitInput()) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 HM Land Registry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/src/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "GOV.UK", 3 | "name": "GOV.UK Frontend Flask", 4 | "icons": [ 5 | { 6 | "src": "images/favicon.ico", 7 | "type": "image/x-icon", 8 | "sizes": "48x48" 9 | }, 10 | { 11 | "src": "images/favicon.svg", 12 | "type": "image/svg+xml", 13 | "sizes": "150x150", 14 | "purpose": "any" 15 | }, 16 | { 17 | "src": "images/govuk-icon-180.png", 18 | "type": "image/png", 19 | "sizes": "180x180", 20 | "purpose": "maskable" 21 | }, 22 | { 23 | "src": "images/govuk-icon-192.png", 24 | "type": "image/png", 25 | "sizes": "192x192", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "images/govuk-icon-512.png", 30 | "type": "image/png", 31 | "sizes": "512x512", 32 | "purpose": "maskable" 33 | }, 34 | { 35 | "src": "images/govuk-icon-mask.svg", 36 | "type": "image/svg+xml", 37 | "sizes": "150x150", 38 | "purpose": "monochrome" 39 | } 40 | ], 41 | "background_color": "#ffffff", 42 | "display": "fullscreen", 43 | "orientation": "natural", 44 | "start_url": "/", 45 | "theme_color": "#1d70b8" 46 | } 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build Python wheels 2 | FROM python:3.14-slim AS builder 3 | 4 | # Install build dependencies only here 5 | RUN apt-get update && apt-get install -y --no-install-recommends \ 6 | gcc \ 7 | libc6-dev \ 8 | libpq-dev \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | WORKDIR /app 12 | 13 | COPY requirements.txt ./ 14 | # Build wheels instead of direct install 15 | RUN pip wheel --no-cache-dir --no-deps -r requirements.txt -w /wheels 16 | 17 | 18 | # Stage 2: Final runtime image 19 | FROM python:3.14-slim 20 | 21 | # Install only runtime libraries 22 | RUN apt-get update && apt-get install -y --no-install-recommends \ 23 | libpq5 \ 24 | && rm -rf /var/lib/apt/lists/* 25 | 26 | # Create non-root user 27 | RUN addgroup --system appgroup && adduser --system --group appuser 28 | 29 | ENV FLASK_APP=govuk-frontend-flask.py \ 30 | PYTHONDONTWRITEBYTECODE=1 \ 31 | PYTHONUNBUFFERED=1 32 | 33 | WORKDIR /home/appuser 34 | 35 | # Install Python packages from wheels 36 | COPY --from=builder /wheels /wheels 37 | RUN pip install --no-cache-dir /wheels/* 38 | 39 | # Copy application code 40 | COPY --chown=appuser:appgroup govuk-frontend-flask.py config.py ./ 41 | COPY --chown=appuser:appgroup app app 42 | COPY --chown=appuser:appgroup migrations migrations 43 | 44 | # Copy entrypoint script into PATH 45 | COPY --chown=appuser:appgroup docker-entrypoint.sh /usr/local/bin/ 46 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 47 | 48 | USER appuser 49 | 50 | ENTRYPOINT ["docker-entrypoint.sh"] 51 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from werkzeug.middleware.proxy_fix import ProxyFix 3 | 4 | from app import create_app 5 | from config import TestConfig 6 | 7 | 8 | def test_create_app() -> None: 9 | """Verify that create_app returns a configured Flask app instance.""" 10 | app: Flask = create_app(TestConfig) 11 | assert isinstance(app, Flask) 12 | 13 | 14 | def test_config_loaded_with_context() -> None: 15 | """Verify config is loaded correctly within an app context.""" 16 | app: Flask = create_app(TestConfig) 17 | with app.app_context(): 18 | assert app.config["DEBUG"] == TestConfig.DEBUG 19 | 20 | 21 | def test_config_loaded() -> None: 22 | """Verify config is loaded correctly (outside app context).""" 23 | app: Flask = create_app(TestConfig) 24 | assert app.config["DEBUG"] == TestConfig.DEBUG 25 | 26 | 27 | def test_jinja_env_config() -> None: 28 | """Verify Jinja environment configuration.""" 29 | app: Flask = create_app(TestConfig) 30 | assert app.jinja_env.lstrip_blocks is True 31 | assert app.jinja_env.trim_blocks is True 32 | 33 | 34 | def test_middleware_applied() -> None: 35 | """Verify that the ProxyFix middleware is applied to the app.""" 36 | app: Flask = create_app(TestConfig) 37 | assert isinstance(app.wsgi_app, ProxyFix) 38 | 39 | 40 | def test_extensions_initialized() -> None: 41 | """Verify that core Flask extensions are initialized.""" 42 | app: Flask = create_app(TestConfig) 43 | assert "csrf" in app.extensions 44 | assert "limiter" in app.extensions 45 | 46 | 47 | def test_blueprints_registered() -> None: 48 | """Verify that blueprints are registered with the app.""" 49 | app: Flask = create_app(TestConfig) 50 | assert "main" in app.blueprints 51 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config(object): 5 | CONTACT_EMAIL = os.environ.get("CONTACT_EMAIL") 6 | CONTACT_PHONE = os.environ.get("CONTACT_PHONE") 7 | DEPARTMENT_NAME = os.environ.get("DEPARTMENT_NAME") 8 | DEPARTMENT_URL = os.environ.get("DEPARTMENT_URL") 9 | RATELIMIT_HEADERS_ENABLED = True 10 | RATELIMIT_STORAGE_URI = os.environ.get("REDIS_URL") 11 | SECRET_KEY = os.environ.get("SECRET_KEY") 12 | SERVICE_NAME = os.environ.get("SERVICE_NAME") 13 | SERVICE_PHASE = os.environ.get("SERVICE_PHASE") 14 | SERVICE_URL = os.environ.get("SERVICE_URL") 15 | SESSION_COOKIE_HTTPONLY = True 16 | SESSION_COOKIE_SAMESITE = "Lax" 17 | SESSION_COOKIE_SECURE = True 18 | SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or ( 19 | f"postgresql://{os.environ.get('POSTGRES_USER')}:" 20 | f"{os.environ.get('POSTGRES_PASSWORD')}@" 21 | f"{os.environ.get('POSTGRES_HOST')}:" 22 | f"{os.environ.get('POSTGRES_PORT')}/" 23 | f"{os.environ.get('POSTGRES_DB')}" 24 | ) 25 | 26 | 27 | class TestConfig(Config): 28 | CONTACT_EMAIL = "test@example.com" 29 | CONTACT_PHONE = "08081570000" 30 | DEBUG = True 31 | DEPARTMENT_NAME = "Department of Magical Law Enforcement" 32 | DEPARTMENT_URL = "https://www.example.com/" 33 | RATELIMIT_HEADERS_ENABLED = True 34 | RATELIMIT_STORAGE_URI = "memory://" 35 | SECRET_KEY = "4f378500459bb58fecf903ea3c113069f11f150b33388f56fc89f7edce0e6a84" # nosec B105 36 | SERVICE_NAME = "Apply for a wand licence" 37 | SERVICE_PHASE = "Beta" 38 | SERVICE_URL = "https://wand-licence.service.gov.uk" 39 | SESSION_COOKIE_HTTPONLY = True 40 | SESSION_COOKIE_SAMESITE = "Lax" 41 | SESSION_COOKIE_SECURE = True 42 | SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" 43 | TESTING = True 44 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable 6 | # packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 10 | name: "Dependency review" 11 | on: 12 | pull_request: 13 | branches: ["latest"] 14 | 15 | # If using a dependency submission action in this workflow this permission will need to be set to: 16 | # 17 | # permissions: 18 | # contents: write 19 | # 20 | # https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/understanding-your-software-supply-chain/using-the-dependency-submission-api 21 | permissions: 22 | contents: read 23 | # Write permissions for pull-requests are required for using the `comment-summary-in-pr` option, comment out if you aren't using this option 24 | pull-requests: write 25 | 26 | jobs: 27 | dependency-review: 28 | runs-on: ubuntu-24.04 29 | steps: 30 | - name: "Checkout repository" 31 | uses: actions/checkout@v4 32 | - name: "Dependency Review" 33 | uses: actions/dependency-review-action@v4 34 | # Commonly enabled options, see https://github.com/actions/dependency-review-action#configuration-options for all available options. 35 | with: 36 | comment-summary-in-pr: always 37 | # fail-on-severity: moderate 38 | # deny-licenses: GPL-1.0-or-later, LGPL-2.0-or-later 39 | # retry-on-snapshot-warnings: true 40 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "govuk-frontend-flask", 3 | "version": "1.0.0", 4 | "description": "GOV.UK Frontend Flask", 5 | "private": true, 6 | "scripts": { 7 | "format:check": "prettier src --check", 8 | "format:fix": "prettier src --write", 9 | "lint:check": "eslint src", 10 | "lint:fix": "eslint src --fix", 11 | "prebuild": "npm run format:check && npm run lint:check", 12 | "build": "webpack", 13 | "watch": "webpack --watch", 14 | "upgrade:latest": "ncu -u", 15 | "upgrade:minor": "ncu -t minor -u", 16 | "upgrade:patch": "ncu -t patch -u" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/LandRegistry/govuk-frontend-flask.git" 21 | }, 22 | "author": { 23 | "name": "Matt Shaw", 24 | "url": "https://matthew-shaw.github.io/" 25 | }, 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/LandRegistry/govuk-frontend-flask/issues" 29 | }, 30 | "homepage": "https://github.com/LandRegistry/govuk-frontend-flask#readme", 31 | "engines": { 32 | "node": "24.x", 33 | "npm": "11.x" 34 | }, 35 | "dependencies": { 36 | "govuk-frontend": "5.13.0" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.28.5", 40 | "@babel/preset-env": "^7.28.5", 41 | "@eslint/js": "^9.39.0", 42 | "babel-loader": "^10.0.0", 43 | "copy-webpack-plugin": "^13.0.1", 44 | "css-loader": "^7.1.2", 45 | "css-minimizer-webpack-plugin": "^7.0.2", 46 | "eslint": "^9.39.0", 47 | "eslint-config-prettier": "^10.1.8", 48 | "globals": "^16.5.0", 49 | "mini-css-extract-plugin": "^2.9.4", 50 | "npm-check-updates": "^19.1.2", 51 | "postcss-loader": "^8.2.0", 52 | "postcss-preset-env": "^10.4.0", 53 | "prettier": "^3.6.2", 54 | "sass": "^1.93.3", 55 | "sass-loader": "^16.0.6", 56 | "webpack": "^5.102.1", 57 | "webpack-cli": "^6.0.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: ["latest"] 9 | pull_request: 10 | branches: ["latest"] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-24.04 18 | strategy: 19 | matrix: 20 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements_dev.txt 32 | pip install -r requirements.txt 33 | - name: Check dependencies for known security vulnerabilities 34 | run: pip-audit -r requirements.txt 35 | - name: Check code for potential security vulnerabilities 36 | run: bandit -r . -x /tests 37 | - name: Static type checking 38 | run: mypy . --install-types --non-interactive 39 | - name: Check code formatting 40 | run: | 41 | black . -t py314 -l 120 --check 42 | isort . -c 43 | - name: Lint with flake8 44 | run: | 45 | # stop the build if there are Python syntax errors or undefined names 46 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 47 | # exit-zero treats all errors as warnings. 48 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics 49 | - name: Test with pytest 50 | run: python -m pytest --cov=app --cov-report=term-missing --cov-branch 51 | -------------------------------------------------------------------------------- /web/src/js/modules/cookie-banner.mjs: -------------------------------------------------------------------------------- 1 | // Don't display the banner if both cookies are already set 2 | if ( 3 | !document.cookie 4 | .split(";") 5 | .some((item) => item.trim().startsWith("functional=")) || 6 | !document.cookie 7 | .split(";") 8 | .some((item) => item.trim().startsWith("analytics=")) 9 | ) { 10 | const cookieBanner = document.getElementById("cookie-banner"); 11 | const defaultMessage = document.getElementById("default-message"); 12 | const acceptedMessage = document.getElementById("accepted-message"); 13 | const rejectedMessage = document.getElementById("rejected-message"); 14 | 15 | // Accept additional cookies 16 | document 17 | .getElementById("accept-cookies") 18 | .addEventListener("click", function () { 19 | document.cookie = 20 | "functional=yes; max-age=31557600; path=/; secure; samesite=lax"; 21 | document.cookie = 22 | "analytics=yes; max-age=31557600; path=/; secure; samesite=lax"; 23 | defaultMessage.hidden = true; 24 | acceptedMessage.hidden = false; 25 | }); 26 | 27 | // Reject additional cookies 28 | document 29 | .getElementById("reject-cookies") 30 | .addEventListener("click", function () { 31 | document.cookie = 32 | "functional=no; max-age=31557600; path=/; secure; samesite=lax"; 33 | document.cookie = 34 | "analytics=no; max-age=31557600; path=/; secure; samesite=lax"; 35 | defaultMessage.hidden = true; 36 | rejectedMessage.hidden = false; 37 | }); 38 | 39 | // Hide accepted message 40 | document 41 | .getElementById("accepted-hide") 42 | .addEventListener("click", function () { 43 | acceptedMessage.hidden = true; 44 | cookieBanner.hidden = true; 45 | }); 46 | 47 | // Hide rejected message 48 | document 49 | .getElementById("rejected-hide") 50 | .addEventListener("click", function () { 51 | rejectedMessage.hidden = true; 52 | cookieBanner.hidden = true; 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from flask import Flask 4 | from flask_limiter import Limiter 5 | from flask_limiter.util import get_remote_address 6 | from flask_migrate import Migrate 7 | from flask_sqlalchemy import SQLAlchemy 8 | from flask_wtf.csrf import CSRFProtect # type: ignore[import] 9 | from govuk_frontend_wtf.main import WTFormsHelpers # type: ignore[import] 10 | from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader 11 | from werkzeug.middleware.proxy_fix import ProxyFix 12 | 13 | from config import Config 14 | 15 | # Initialize Flask extensions. These are initialized here for easier access. 16 | csrf: CSRFProtect = CSRFProtect() 17 | db: SQLAlchemy = SQLAlchemy() 18 | limiter: Limiter = Limiter(get_remote_address, default_limits=["2 per second", "60 per minute"]) 19 | migrate: Migrate = Migrate() 20 | 21 | 22 | def create_app(config_class: Type[Config] = Config) -> Flask: 23 | """Create and configure the Flask application. 24 | 25 | Args: 26 | config_class: The configuration class to use (defaults to `Config`). 27 | 28 | Returns: 29 | A configured Flask application instance. 30 | """ 31 | app: Flask = Flask(__name__) # type: ignore[assignment] 32 | app.config.from_object(config_class) 33 | app.jinja_env.globals["govukRebrand"] = True 34 | app.jinja_env.lstrip_blocks = True 35 | app.jinja_env.trim_blocks = True 36 | 37 | # Configure Jinja2 template loader to search in multiple locations. 38 | app.jinja_loader = ChoiceLoader( 39 | [ 40 | PackageLoader("app"), # Load templates from the 'app' package. 41 | PrefixLoader( 42 | { 43 | "govuk_frontend_jinja": PackageLoader("govuk_frontend_jinja"), 44 | "govuk_frontend_wtf": PackageLoader("govuk_frontend_wtf"), 45 | } 46 | ), 47 | ] 48 | ) 49 | 50 | # Use ProxyFix middleware to handle proxies correctly. 51 | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # type: ignore[method-assign] 52 | 53 | # Initialize Flask extensions 54 | csrf.init_app(app) 55 | db.init_app(app) 56 | limiter.init_app(app) 57 | migrate.init_app(app, db) 58 | WTFormsHelpers(app) 59 | 60 | # Register blueprints. These define different sections of the application. 61 | from app.main import bp as main_bp 62 | 63 | app.register_blueprint(main_bp) 64 | 65 | return app 66 | 67 | 68 | from app import models # noqa: E402,F401 69 | -------------------------------------------------------------------------------- /app/templates/main/index.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- block beforeContent -%} 4 | {{ super() }} 5 | {%- endblock -%} 6 | 7 | {%- block content -%} 8 |
9 |
10 |

Hello, World!

11 |

This is a template Flask 12 | app using the GOV.UK Frontend and 13 | GOV.UK Design System which is designed to 14 | get a new project started quicker. 15 |

16 |

It is also the reference implementation of two core packages:

17 |
    18 |
  • GOV.UK Frontend Jinja 19 | which provides Jinja macros of GOV.UK components
  • 20 |
  • GOV.UK Frontend WTForms 21 | which provides WTForms widgets to integrate the above Jinja macros into form generation and validation
  • 22 |
23 |

The app is provided intentionally bare, with just the essential parts that all services need, 24 | such as error pages, accessibility statement, cookie banner, cookie page and privacy notice.

25 | 26 |

Features

27 |

A number of other packages are used to provide the features listed below with sensible and 28 | best-practice defaults:

29 |
    30 |
  • Asset management
  • 31 |
  • Cache busting
  • 32 |
  • Form generation and validation
  • 33 |
  • Form error handing
  • 34 |
  • Flash messages
  • 35 |
  • CSRF protection
  • 36 |
  • HTTP security headers
  • 37 |
  • Content Security Policy (CSP)
  • 38 |
  • Response compression
  • 39 |
  • Rate limiting
  • 40 |
41 | 42 |

Documentation

43 |

Detailed documentation on the features listed above and the next steps to start building out your app on top of this template is on GitHub

45 |
46 |
47 | {%- endblock -%} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.14 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | alembic==1.17.0 8 | # via flask-migrate 9 | blinker==1.9.0 10 | # via flask 11 | click==8.3.0 12 | # via flask 13 | deepmerge==2.0 14 | # via govuk-frontend-wtf 15 | deprecated==1.2.18 16 | # via limits 17 | dnspython==2.8.0 18 | # via email-validator 19 | email-validator==2.3.0 20 | # via -r requirements.in 21 | flask==3.1.2 22 | # via 23 | # -r requirements.in 24 | # flask-limiter 25 | # flask-migrate 26 | # flask-sqlalchemy 27 | # flask-wtf 28 | # govuk-frontend-wtf 29 | flask-limiter[redis]==4.0.0 30 | # via -r requirements.in 31 | flask-migrate==4.1.0 32 | # via -r requirements.in 33 | flask-sqlalchemy==3.1.1 34 | # via 35 | # -r requirements.in 36 | # flask-migrate 37 | flask-wtf==1.2.2 38 | # via govuk-frontend-wtf 39 | govuk-frontend-jinja==3.9.0 40 | # via 41 | # -r requirements.in 42 | # govuk-frontend-wtf 43 | govuk-frontend-wtf==3.2.0 44 | # via -r requirements.in 45 | greenlet==3.2.4 46 | # via sqlalchemy 47 | gunicorn==23.0.0 48 | # via -r requirements.in 49 | idna==3.11 50 | # via email-validator 51 | itsdangerous==2.2.0 52 | # via 53 | # flask 54 | # flask-wtf 55 | jinja2==3.1.6 56 | # via 57 | # flask 58 | # govuk-frontend-jinja 59 | # govuk-frontend-wtf 60 | limits[redis]==5.6.0 61 | # via flask-limiter 62 | mako==1.3.10 63 | # via alembic 64 | markdown-it-py==4.0.0 65 | # via rich 66 | markupsafe==3.0.3 67 | # via 68 | # flask 69 | # jinja2 70 | # mako 71 | # werkzeug 72 | # wtforms 73 | mdurl==0.1.2 74 | # via markdown-it-py 75 | ordered-set==4.1.0 76 | # via flask-limiter 77 | packaging==25.0 78 | # via 79 | # gunicorn 80 | # limits 81 | psycopg2==2.9.11 82 | # via -r requirements.in 83 | pygments==2.19.2 84 | # via rich 85 | redis==6.4.0 86 | # via limits 87 | rich==14.2.0 88 | # via flask-limiter 89 | sqlalchemy==2.0.44 90 | # via 91 | # alembic 92 | # flask-sqlalchemy 93 | typing-extensions==4.15.0 94 | # via 95 | # alembic 96 | # flask-limiter 97 | # limits 98 | # sqlalchemy 99 | werkzeug==3.1.3 100 | # via flask 101 | wrapt==1.17.3 102 | # via deprecated 103 | wtforms==3.2.1 104 | # via 105 | # flask-wtf 106 | # govuk-frontend-wtf 107 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require("copy-webpack-plugin"); 2 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const path = require("path"); 5 | const postcssPresetEnv = require("postcss-preset-env"); 6 | 7 | module.exports = { 8 | mode: "production", 9 | devtool: "source-map", 10 | entry: ["./src/js/main.mjs", "./src/scss/main.scss"], 11 | output: { 12 | filename: "[name].min.js?[contenthash]", 13 | path: path.resolve(__dirname, "dist"), 14 | clean: true, 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.scss$/, 20 | use: [ 21 | MiniCssExtractPlugin.loader, 22 | "css-loader", 23 | { 24 | loader: "postcss-loader", 25 | options: { 26 | postcssOptions: { 27 | plugins: [postcssPresetEnv], 28 | }, 29 | }, 30 | }, 31 | { 32 | loader: "sass-loader", 33 | options: { 34 | sassOptions: { 35 | quietDeps: true, 36 | }, 37 | }, 38 | }, 39 | ], 40 | }, 41 | { 42 | test: /\.(?:js|mjs|cjs)$/, 43 | exclude: /node_modules/, 44 | use: { 45 | loader: "babel-loader", 46 | options: { 47 | presets: [ 48 | [ 49 | "@babel/preset-env", 50 | { 51 | bugfixes: true, 52 | loose: true, 53 | }, 54 | ], 55 | ], 56 | }, 57 | }, 58 | }, 59 | { 60 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 61 | type: "asset/resource", 62 | generator: { 63 | filename: "assets/images/[name][ext][query]", 64 | }, 65 | }, 66 | { 67 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 68 | type: "asset/resource", 69 | generator: { 70 | filename: "assets/fonts/[name][ext][query]", 71 | }, 72 | }, 73 | ], 74 | }, 75 | plugins: [ 76 | new MiniCssExtractPlugin({ 77 | filename: "[name].min.css?[contenthash]", 78 | }), 79 | new CopyPlugin({ 80 | patterns: [ 81 | { 82 | from: "src/assets/manifest.json", 83 | to: "assets/manifest.json", 84 | }, 85 | { 86 | from: "./node_modules/govuk-frontend/dist/govuk/assets/rebrand", 87 | to: "assets", 88 | }, 89 | ], 90 | }), 91 | ], 92 | resolve: { 93 | modules: [path.resolve(__dirname, "node_modules")], 94 | }, 95 | optimization: { 96 | minimizer: [`...`, new CssMinimizerPlugin()], 97 | }, 98 | }; 99 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | The following are some guidelines to help you make a valuable contribution to this project quicker and easier. 4 | 5 | There are two main types of contribution that we welcome; issues and pull requests. 6 | 7 | ## Create an issue 8 | 9 | If you want to raise a bug report to help us improve, or a feature request to suggest an idea for the project, then follow these guidelines: 10 | 11 | 1. Check the [current issues](https://github.com/LandRegistry/govuk-frontend-flask/issues) first to see if anyone else has already found the same issue/bug or suggested the same feature/enhancement. If they have, please add a comment to the existing issue to help us understand the scale, impact or demand for that change. 12 | 13 | 2. If your contribution is not already covered in an existing issue, please [create a new bug report or feature request](https://github.com/LandRegistry/govuk-frontend-flask/issues/new/choose) using the appropriate issue templates. Please fill out as much of the template as possible to help us understand the details. 14 | 15 | 3. Your new issue will be reviewed by one of the project maintainers, who may ask for more detail, suggest another course of action, or prioritise and allocate it to a future milestone. 16 | 17 | ## Create a pull request 18 | 19 | If you want to contribute code directly to the project in order to fix a bug or add a feature, then follow these guidelines: 20 | 21 | 1. Check the [current issues](https://github.com/LandRegistry/govuk-frontend-flask/issues) and [pull requests](https://github.com/LandRegistry/govuk-frontend-flask/pulls) first to see if anyone else is already working on the same bug or feature. If they are, consider contributing to that existing issue or pull request first, rather than duplicating work. 22 | 23 | 2. If your proposed contribution is a new one, please [create a fork](https://guides.github.com/activities/forking/) of the repositiory. 24 | 25 | 3. Make your contributions to your fork and ensure that the build pipeline still passes. This includes checking dependencies are up-to-date and clear of any security vulnerabilities, running code linting and running unit tests. See the [GitHub workflows](.github/workflows) for details of exactly what the build pipeline will run. 26 | 27 | 4. Format your Python code with [Black](https://pypi.org/project/black/) using `black . -l 120` for consistency with the existing codebase. 28 | 29 | 5. If the build passes, [create a pull request](https://guides.github.com/activities/forking/#making-a-pull-request). Please provide as much useful information about your change as possible. 30 | 31 | 6. Your new pull request will be reviewed by one of the project maintainers, who may ask for more detail, suggest another course of action, or prioritise and allocate it to a future milestone. 32 | -------------------------------------------------------------------------------- /web/nginx.conf: -------------------------------------------------------------------------------- 1 | # generated 2024-11-01, Mozilla Guideline v5.7, nginx 1.26.2, OpenSSL 3.3.2, modern configuration, no OCSP 2 | # https://ssl-config.mozilla.org/#server=nginx&version=1.26.2&config=modern&openssl=3.3.2&ocsp=false&guideline=5.7 3 | server { 4 | listen 80 default_server; 5 | listen [::]:80 default_server; 6 | 7 | location / { 8 | return 301 https://$host$request_uri; 9 | } 10 | } 11 | 12 | server { 13 | listen 443 ssl; 14 | listen [::]:443 ssl; 15 | http2 on; 16 | 17 | ssl_certificate /etc/nginx/ssl/req.pem; 18 | ssl_certificate_key /etc/nginx/ssl/key.pem; 19 | ssl_session_timeout 1d; 20 | ssl_session_cache shared:MozSSL:10m; # about 40000 sessions 21 | ssl_session_tickets off; 22 | 23 | # modern configuration 24 | ssl_protocols TLSv1.3; 25 | ssl_prefer_server_ciphers off; 26 | 27 | # security headers 28 | add_header Content-Security-Policy "script-src 'self' 'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" always; 29 | add_header Cross-Origin-Embedder-Policy "require-corp" always; 30 | add_header Cross-Origin-Opener-Policy "same-origin" always; 31 | add_header Cross-Origin-Resource-Policy "same-origin" always; 32 | add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()" always; 33 | add_header Referrer-Policy "strict-origin-when-cross-origin" always; 34 | add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 35 | add_header X-Content-Type-Options "nosniff" always; 36 | 37 | # enable gzip compression 38 | gzip on; 39 | gzip_comp_level 6; 40 | gzip_proxied any; 41 | gzip_types application/javascript application/json application/xml font/otf font/ttf font/woff font/woff2 image/gif image/jpeg image/png image/svg+xml image/webp text/css text/csv text/javascript text/xml; 42 | 43 | location / { 44 | # forward application requests to the gunicorn server 45 | proxy_pass http://app:5000; 46 | proxy_redirect off; 47 | proxy_set_header Host $host; 48 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 49 | proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; 50 | proxy_set_header X-Real-IP $remote_addr; 51 | } 52 | 53 | location /static/ { 54 | # serve static files directly 55 | alias /web/static/; 56 | 57 | sendfile on; 58 | tcp_nopush on; 59 | 60 | # set long lived cache 61 | add_header Cache-Control "max-age=604800, stale-while-revalidate=86400" always; 62 | } 63 | } -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: . 4 | container_name: app 5 | restart: always 6 | environment: 7 | CONTACT_EMAIL: ${CONTACT_EMAIL} 8 | CONTACT_PHONE: ${CONTACT_PHONE} 9 | DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} 10 | DEPARTMENT_NAME: ${DEPARTMENT_NAME} 11 | DEPARTMENT_URL: ${DEPARTMENT_URL} 12 | REDIS_URL: redis://${REDIS_HOST}:${REDIS_PORT} 13 | SECRET_KEY: ${SECRET_KEY} 14 | SERVICE_NAME: ${SERVICE_NAME} 15 | SERVICE_PHASE: ${SERVICE_PHASE} 16 | SERVICE_URL: ${SERVICE_URL} 17 | expose: 18 | - 5000 19 | healthcheck: 20 | test: 21 | [ 22 | "CMD", 23 | "python", 24 | "-c", 25 | "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')", 26 | ] 27 | interval: 30s 28 | timeout: 5s 29 | retries: 3 30 | start_period: 10s 31 | depends_on: 32 | db: 33 | condition: service_healthy 34 | cache: 35 | condition: service_healthy 36 | develop: 37 | watch: 38 | - action: rebuild 39 | path: ./requirements.txt 40 | target: /home/appuser/requirements.txt 41 | - action: sync+restart 42 | path: ./app 43 | target: /home/appuser/app 44 | 45 | db: 46 | image: postgres:18-alpine 47 | container_name: db 48 | restart: always 49 | ports: 50 | - 5432:5432 51 | expose: 52 | - 5432 53 | shm_size: 128mb 54 | environment: 55 | POSTGRES_DB: ${POSTGRES_DB} 56 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 57 | POSTGRES_USER: ${POSTGRES_USER} 58 | volumes: 59 | - pg_data:/var/lib/postgresql/data 60 | healthcheck: 61 | test: 62 | ["CMD", "pg_isready", "-U", "${POSTGRES_USER}", "-d", "${POSTGRES_DB}"] 63 | interval: 30s 64 | timeout: 5s 65 | retries: 5 66 | start_period: 10s 67 | 68 | cache: 69 | image: redis:7-alpine 70 | container_name: cache 71 | restart: always 72 | expose: 73 | - 6379 74 | healthcheck: 75 | test: ["CMD", "redis-cli", "-h", "localhost", "ping"] 76 | interval: 30s 77 | timeout: 3s 78 | retries: 5 79 | start_period: 10s 80 | volumes: 81 | - redis_data:/data 82 | 83 | web: 84 | build: ./web 85 | container_name: web 86 | restart: always 87 | ports: 88 | - 443:443 89 | healthcheck: 90 | test: ["CMD", "curl", "-kfs", "https://localhost/health"] 91 | interval: 30s 92 | timeout: 5s 93 | retries: 3 94 | start_period: 10s 95 | depends_on: 96 | app: 97 | condition: service_healthy 98 | develop: 99 | watch: 100 | - action: rebuild 101 | path: ./web/package.json 102 | - action: sync+restart 103 | path: ./web/nginx.conf 104 | target: /etc/nginx/conf.d/nginx.conf 105 | - action: rebuild 106 | path: ./web/src 107 | 108 | volumes: 109 | redis_data: 110 | pg_data: 111 | -------------------------------------------------------------------------------- /app/main/routes.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from flask import ( 4 | Response, 5 | flash, 6 | make_response, 7 | redirect, 8 | render_template, 9 | request, 10 | url_for, 11 | ) 12 | from flask_wtf.csrf import CSRFError # type: ignore 13 | from werkzeug.exceptions import HTTPException 14 | 15 | from app.main import bp 16 | from app.main.forms import CookiesForm 17 | 18 | 19 | @bp.route("/", methods=["GET"]) 20 | def index() -> str: 21 | """Render the index page.""" 22 | return render_template("index.html") 23 | 24 | 25 | @bp.route("/accessibility", methods=["GET"]) 26 | def accessibility() -> str: 27 | """Render the accessibility statement page.""" 28 | return render_template("accessibility.html") 29 | 30 | 31 | @bp.route("/cookies", methods=["GET", "POST"]) 32 | def cookies() -> Union[str, Response]: 33 | """Handle GET and POST requests for managing cookie preferences.""" 34 | form: CookiesForm = CookiesForm() 35 | # Initialize cookie settings. Defaults to rejecting all cookies. 36 | functional: str = "no" 37 | analytics: str = "no" 38 | 39 | if form.validate_on_submit(): 40 | # Update cookie settings based on form submission 41 | functional = form.functional.data 42 | analytics = form.analytics.data 43 | # Create flash message confirmation before rendering template 44 | flash("You've set your cookie preferences.", "success") 45 | # Create the response so we can set cookies before returning 46 | response: Response = make_response(render_template("cookies.html", form=form)) 47 | 48 | # Set individual cookies in the response 49 | response.set_cookie("functional", functional, max_age=31557600, secure=True, samesite="Lax") 50 | response.set_cookie("analytics", analytics, max_age=31557600, secure=True, samesite="Lax") 51 | 52 | return response 53 | elif request.method == "GET": 54 | # Retrieve existing cookie settings if present 55 | functional = request.cookies.get("functional", "no") 56 | analytics = request.cookies.get("analytics", "no") 57 | 58 | # Pre-populate form with existing settings 59 | form.functional.data = functional 60 | form.analytics.data = analytics 61 | 62 | return render_template("cookies.html", form=form) 63 | 64 | 65 | @bp.route("/privacy", methods=["GET"]) 66 | def privacy() -> str: 67 | """Render the privacy policy page.""" 68 | return render_template("privacy.html") 69 | 70 | 71 | @bp.route("/health", methods=["GET"]) 72 | def health() -> Response: 73 | """Route for healthchecks""" 74 | return make_response("OK", 200) 75 | 76 | 77 | @bp.app_errorhandler(HTTPException) 78 | def handle_http_exception(error: HTTPException) -> Response: 79 | """Handle HTTP exceptions and render appropriate error template.""" 80 | return make_response(render_template(f"{error.code}.html"), error.code) 81 | 82 | 83 | @bp.app_errorhandler(CSRFError) 84 | def handle_csrf_error(error: CSRFError) -> Response: 85 | """Handle CSRF errors and display a flash message.""" 86 | flash("The form you were submitting has expired. Please try again.") 87 | return make_response(redirect(url_for("main.index"))) 88 | -------------------------------------------------------------------------------- /app/templates/main/cookies.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- from 'govuk_frontend_jinja/components/back-link/macro.html' import govukBackLink -%} 4 | {%- from 'govuk_frontend_jinja/components/table/macro.html' import govukTable -%} 5 | 6 | {%- block pageTitle -%}{%- if form.errors -%}Error: {%- endif -%}Cookies – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 7 | 8 | {%- block beforeContent -%} 9 | {{ super() }} 10 | {{ govukBackLink({ 11 | 'text': "Back", 12 | 'href': url_for('main.index') 13 | }) }} 14 | {%- endblock -%} 15 | 16 | {%- block content -%} 17 |
18 |
19 | {{ super() }} 20 |

Cookies

21 |

Cookies are small files saved on your phone, tablet or computer when you visit a website. 22 |

23 |

We use cookies to make {{config['SERVICE_NAME']}} work and collect information about how you use our 24 | service.

25 | 26 |

Essential cookies

27 |

Essential cookies keep your information secure while you use {{config['SERVICE_NAME']}}. We do not need 28 | to ask permission to use them.

29 | {{ govukTable( 30 | { 31 | 'head': [{'text': 'Name'}, {'text': 'Purpose'}, {'text': 'Expires'}], 32 | 'rows': [ 33 | [{'text': 'cookie_policy'}, {'text': 'Saves your cookie consent settings'}, {'text': '1 year'}], 34 | [{'text': 'session'}, {'text': 'Temporary storage'}, {'text': 'Session'}] 35 | ] 36 | } 37 | )}} 38 | 39 |

Functional cookies

40 |

Functional cookies allow you to take advantage of some functionality, for example remembering 41 | settings between visits. The service will work without them.

42 | {{ govukTable( 43 | { 44 | 'head': [{'text': 'Name'}, {'text': 'Purpose'}, {'text': 'Expires'}], 45 | 'rows': [ 46 | [{'text': 'foo'}, {'text': 'bar'}, {'text': 'baz'}] 47 | ] 48 | } 49 | )}} 50 | 51 |

Analytics cookies

52 |

With your permission, we use Google Analytics to collect data about how you use {{config['SERVICE_NAME']}}. This 53 | information helps us to improve our service.

54 |

Google is not allowed to use or share our analytics data with anyone.

55 |

Google Analytics stores anonymised information about:

56 |
    57 |
  • how you got to {{config['SERVICE_NAME']}}
  • 58 |
  • the pages you visit on {{config['SERVICE_NAME']}} and how long you spend on them
  • 59 |
  • any errors you see while using {{config['SERVICE_NAME']}}
  • 60 |
61 | {{ govukTable( 62 | { 63 | 'head': [{'text': 'Name'}, {'text': 'Purpose'}, {'text': 'Expires'}], 64 | 'rows': [ 65 | [{'text': 'foo'}, {'text': 'bar'}, {'text': 'baz'}] 66 | ] 67 | } 68 | )}} 69 | 70 |

Change your cookie settings

71 |
72 | {{ form.csrf_token }} 73 | {{ form.functional }} 74 | {{ form.analytics }} 75 | {{ form.save }} 76 |
77 |
78 |
79 | {%- endblock -%} -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [contact email]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | 77 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.14 3 | # by the following command: 4 | # 5 | # pip-compile requirements_dev.in 6 | # 7 | attrs==25.4.0 8 | # via flake8-bugbear 9 | bandit==1.8.6 10 | # via -r requirements_dev.in 11 | black==25.9.0 12 | # via -r requirements_dev.in 13 | boolean-py==5.0 14 | # via license-expression 15 | build==1.3.0 16 | # via pip-tools 17 | cachecontrol[filecache]==0.14.3 18 | # via 19 | # cachecontrol 20 | # pip-audit 21 | certifi==2025.10.5 22 | # via requests 23 | charset-normalizer==3.4.4 24 | # via requests 25 | click==8.3.0 26 | # via 27 | # black 28 | # pip-tools 29 | # pur 30 | coverage[toml]==7.11.0 31 | # via pytest-cov 32 | cyclonedx-python-lib==9.1.0 33 | # via pip-audit 34 | defusedxml==0.7.1 35 | # via py-serializable 36 | filelock==3.20.0 37 | # via cachecontrol 38 | flake8==7.3.0 39 | # via 40 | # flake8-bugbear 41 | # pep8-naming 42 | flake8-bugbear==25.10.21 43 | # via -r requirements_dev.in 44 | idna==3.11 45 | # via requests 46 | iniconfig==2.3.0 47 | # via pytest 48 | isort==7.0.0 49 | # via -r requirements_dev.in 50 | license-expression==30.4.4 51 | # via cyclonedx-python-lib 52 | markdown-it-py==4.0.0 53 | # via rich 54 | mccabe==0.7.0 55 | # via flake8 56 | mdurl==0.1.2 57 | # via markdown-it-py 58 | msgpack==1.1.2 59 | # via cachecontrol 60 | mypy==1.18.2 61 | # via -r requirements_dev.in 62 | mypy-extensions==1.1.0 63 | # via 64 | # black 65 | # mypy 66 | packageurl-python==0.17.5 67 | # via cyclonedx-python-lib 68 | packaging==25.0 69 | # via 70 | # black 71 | # build 72 | # pip-audit 73 | # pip-requirements-parser 74 | # pytest 75 | pathspec==0.12.1 76 | # via 77 | # black 78 | # mypy 79 | pep8-naming==0.15.1 80 | # via -r requirements_dev.in 81 | pip-api==0.0.34 82 | # via pip-audit 83 | pip-audit==2.9.0 84 | # via -r requirements_dev.in 85 | pip-requirements-parser==32.0.1 86 | # via pip-audit 87 | pip-tools==7.5.1 88 | # via -r requirements_dev.in 89 | platformdirs==4.5.0 90 | # via 91 | # black 92 | # pip-audit 93 | pluggy==1.6.0 94 | # via 95 | # pytest 96 | # pytest-cov 97 | pur==7.3.3 98 | # via -r requirements_dev.in 99 | py-serializable==2.1.0 100 | # via cyclonedx-python-lib 101 | pycodestyle==2.14.0 102 | # via flake8 103 | pyflakes==3.4.0 104 | # via flake8 105 | pygments==2.19.2 106 | # via 107 | # pytest 108 | # rich 109 | pyparsing==3.2.5 110 | # via pip-requirements-parser 111 | pyproject-hooks==1.2.0 112 | # via 113 | # build 114 | # pip-tools 115 | pytest==8.4.2 116 | # via pytest-cov 117 | pytest-cov==7.0.0 118 | # via -r requirements_dev.in 119 | python-dotenv==1.2.1 120 | # via -r requirements_dev.in 121 | pytokens==0.2.0 122 | # via black 123 | pyyaml==6.0.3 124 | # via bandit 125 | requests==2.32.5 126 | # via 127 | # cachecontrol 128 | # pip-audit 129 | rich==14.2.0 130 | # via 131 | # bandit 132 | # pip-audit 133 | sortedcontainers==2.4.0 134 | # via cyclonedx-python-lib 135 | stevedore==5.5.0 136 | # via bandit 137 | toml==0.10.2 138 | # via pip-audit 139 | typing-extensions==4.15.0 140 | # via mypy 141 | urllib3==2.5.0 142 | # via requests 143 | wheel==0.45.1 144 | # via pip-tools 145 | 146 | # The following packages are considered to be unsafe in a requirements file: 147 | # pip 148 | # setuptools 149 | -------------------------------------------------------------------------------- /tests/test_main_routes.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import pytest 4 | from flask import Flask 5 | from flask.testing import FlaskClient 6 | 7 | from app import create_app 8 | from config import TestConfig 9 | 10 | 11 | @pytest.fixture 12 | def app() -> Generator[FlaskClient, None, None]: 13 | """ 14 | Create and configure a new app instance for each test. 15 | 16 | Returns: 17 | Flask: The Flask application instance. 18 | """ 19 | app: Flask = create_app(TestConfig) 20 | app.config["WTF_CSRF_ENABLED"] = False # Disable CSRF for testing 21 | with app.test_client() as client: 22 | yield client 23 | 24 | 25 | def test_index(app: FlaskClient) -> None: 26 | """ 27 | Test the index route. 28 | 29 | Args: 30 | client (FlaskClient): The test client for the Flask application. 31 | """ 32 | response = app.get("/") 33 | assert response.status_code == 200 34 | assert b"" in response.data 35 | 36 | 37 | def test_accessibility(app: FlaskClient) -> None: 38 | """ 39 | Test the accessibility route. 40 | 41 | Args: 42 | client (FlaskClient): The test client for the Flask application. 43 | """ 44 | response = app.get("/accessibility") 45 | assert response.status_code == 200 46 | assert b"<title>" in response.data 47 | 48 | 49 | def test_privacy(app: FlaskClient) -> None: 50 | """ 51 | Test the privacy route. 52 | 53 | Args: 54 | client (FlaskClient): The test client for the Flask application. 55 | """ 56 | response = app.get("/privacy") 57 | assert response.status_code == 200 58 | assert b"<title>" in response.data 59 | 60 | 61 | def test_cookies_get(app: FlaskClient) -> None: 62 | """Test the cookies route with a GET request.""" 63 | response = app.get("/cookies") 64 | assert response.status_code == 200 65 | 66 | # Check default cookie values 67 | assert response.request.cookies.get("functional", "no") == "no" 68 | assert response.request.cookies.get("analytics", "no") == "no" 69 | 70 | 71 | def test_cookies_post(app: FlaskClient) -> None: 72 | """Test the cookies route with a POST request.""" 73 | data = {"functional": "yes", "analytics": "yes"} 74 | 75 | response = app.post("/cookies", data=data, follow_redirects=True) 76 | assert response.status_code == 200 77 | 78 | # Verify flash message 79 | assert b"You've set your cookie preferences." in response.data 80 | 81 | # Check individual cookies were set 82 | cookies = response.headers.getlist("Set-Cookie") 83 | functional_cookie = [c for c in cookies if c.startswith("functional=")][0] 84 | analytics_cookie = [c for c in cookies if c.startswith("analytics=")][0] 85 | 86 | # Verify cookie values 87 | assert "functional=yes" in functional_cookie 88 | assert "analytics=yes" in analytics_cookie 89 | 90 | # Verify cookie attributes 91 | for cookie in [functional_cookie, analytics_cookie]: 92 | assert "Max-Age=31557600" in cookie 93 | assert "Secure" in cookie 94 | assert "SameSite=Lax" in cookie 95 | 96 | 97 | def test_http_errors(app: FlaskClient) -> None: 98 | """Test handling of HTTP errors.""" 99 | response = app.get("/not-found") 100 | assert response.status_code == 404 101 | assert b"Page not found" in response.data 102 | 103 | response = app.get("/") 104 | response = app.get("/") 105 | response = app.get("/") 106 | assert response.status_code == 429 107 | assert b"There have been too many attempts to access this page." in response.data 108 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from flask import current_app 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) # type: ignore[arg-type] 14 | logger = logging.getLogger("alembic.env") 15 | 16 | 17 | def get_engine(): 18 | try: 19 | # this works with Flask-SQLAlchemy<3 and Alchemical 20 | return current_app.extensions["migrate"].db.get_engine() 21 | except (TypeError, AttributeError): 22 | # this works with Flask-SQLAlchemy>=3 23 | return current_app.extensions["migrate"].db.engine 24 | 25 | 26 | def get_engine_url(): 27 | try: 28 | return get_engine().url.render_as_string(hide_password=False).replace("%", "%%") 29 | except AttributeError: 30 | return str(get_engine().url).replace("%", "%%") 31 | 32 | 33 | # add your model's MetaData object here 34 | # for 'autogenerate' support 35 | # from myapp import mymodel 36 | # target_metadata = mymodel.Base.metadata 37 | config.set_main_option("sqlalchemy.url", get_engine_url()) 38 | target_db = current_app.extensions["migrate"].db 39 | 40 | # other values from the config, defined by the needs of env.py, 41 | # can be acquired: 42 | # my_important_option = config.get_main_option("my_important_option") 43 | # ... etc. 44 | 45 | 46 | def get_metadata(): 47 | if hasattr(target_db, "metadatas"): 48 | return target_db.metadatas[None] 49 | return target_db.metadata 50 | 51 | 52 | def run_migrations_offline(): 53 | """Run migrations in 'offline' mode. 54 | 55 | This configures the context with just a URL 56 | and not an Engine, though an Engine is acceptable 57 | here as well. By skipping the Engine creation 58 | we don't even need a DBAPI to be available. 59 | 60 | Calls to context.execute() here emit the given string to the 61 | script output. 62 | 63 | """ 64 | url = config.get_main_option("sqlalchemy.url") 65 | context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | def run_migrations_online(): 72 | """Run migrations in 'online' mode. 73 | 74 | In this scenario we need to create an Engine 75 | and associate a connection with the context. 76 | 77 | """ 78 | 79 | # this callback is used to prevent an auto-migration from being generated 80 | # when there are no changes to the schema 81 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 82 | def process_revision_directives(context, revision, directives): 83 | if getattr(config.cmd_opts, "autogenerate", False): 84 | script = directives[0] 85 | if script.upgrade_ops.is_empty(): 86 | directives[:] = [] 87 | logger.info("No changes in schema detected.") 88 | 89 | conf_args = current_app.extensions["migrate"].configure_args 90 | if conf_args.get("process_revision_directives") is None: 91 | conf_args["process_revision_directives"] = process_revision_directives 92 | 93 | connectable = get_engine() 94 | 95 | with connectable.connect() as connection: 96 | context.configure(connection=connection, target_metadata=get_metadata(), **conf_args) 97 | 98 | with context.begin_transaction(): 99 | context.run_migrations() 100 | 101 | 102 | if context.is_offline_mode(): 103 | run_migrations_offline() 104 | else: 105 | run_migrations_online() 106 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["latest"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["latest"] 20 | schedule: 21 | - cron: "40 16 * * 6" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | # Runner size impacts CodeQL analysis time. To learn more, please see: 27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 28 | # - https://gh.io/supported-runners-and-hardware-resources 29 | # - https://gh.io/using-larger-runners 30 | # Consider using larger runners for possible analysis time improvements. 31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-24.04' }} 32 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: ["javascript-typescript", "python"] 42 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 43 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 44 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 45 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v2 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v2 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v2 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | schedule: 10 | - cron: '35 19 * * *' 11 | push: 12 | branches: [ "latest" ] 13 | # Publish semver tags as releases. 14 | tags: [ '[0-9]*.[0-9]*.[0-9]*' ] 15 | pull_request: 16 | branches: [ "latest" ] 17 | 18 | env: 19 | # Use docker.io for Docker Hub if empty 20 | REGISTRY: ghcr.io 21 | # github.repository as <account>/<repo> 22 | IMAGE_NAME: ${{ github.repository }} 23 | 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | packages: write 32 | # This is used to complete the identity challenge 33 | # with sigstore/fulcio when running outside of PRs. 34 | id-token: write 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | # Install the cosign tool except on PR 41 | # https://github.com/sigstore/cosign-installer 42 | - name: Install cosign 43 | if: github.event_name != 'pull_request' 44 | uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 45 | with: 46 | cosign-release: 'v2.2.4' 47 | 48 | # Set up BuildKit Docker container builder to be able to build 49 | # multi-platform images and export cache 50 | # https://github.com/docker/setup-buildx-action 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 53 | 54 | # Login against a Docker registry except on PR 55 | # https://github.com/docker/login-action 56 | - name: Log into registry ${{ env.REGISTRY }} 57 | if: github.event_name != 'pull_request' 58 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 59 | with: 60 | registry: ${{ env.REGISTRY }} 61 | username: ${{ github.actor }} 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | # Extract metadata (tags, labels) for Docker 65 | # https://github.com/docker/metadata-action 66 | - name: Extract Docker metadata 67 | id: meta 68 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 69 | with: 70 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 71 | 72 | # Build and push Docker image with Buildx (don't push on PR) 73 | # https://github.com/docker/build-push-action 74 | - name: Build and push Docker image 75 | id: build-and-push 76 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 77 | with: 78 | context: . 79 | push: ${{ github.event_name != 'pull_request' }} 80 | tags: ${{ steps.meta.outputs.tags }} 81 | labels: ${{ steps.meta.outputs.labels }} 82 | cache-from: type=gha 83 | cache-to: type=gha,mode=max 84 | 85 | # Sign the resulting Docker image digest except on PRs. 86 | # This will only write to the public Rekor transparency log when the Docker 87 | # repository is public to avoid leaking data. If you would like to publish 88 | # transparency data even for private images, pass --force to cosign below. 89 | # https://github.com/sigstore/cosign 90 | - name: Sign the published Docker image 91 | if: ${{ github.event_name != 'pull_request' }} 92 | env: 93 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 94 | TAGS: ${{ steps.meta.outputs.tags }} 95 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 96 | # This step uses the identity token to provision an ephemeral certificate 97 | # against the sigstore community Fulcio instance. 98 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 99 | -------------------------------------------------------------------------------- /web/src/scss/main.scss: -------------------------------------------------------------------------------- 1 | $govuk-assets-path: "../../node_modules/govuk-frontend/dist/govuk/assets/"; 2 | 3 | // 'Base' includes everything from settings, tools and helpers. Nothing 4 | // in the base outputs any CSS. 5 | 6 | @import "govuk-frontend/dist/govuk/base"; 7 | 8 | // Basic content styles for typography, links etc. Approximately 10% of 9 | // the CSS output if you include everything. 10 | 11 | @import "govuk-frontend/dist/govuk/core"; 12 | 13 | // Objects include things like the page template, grid and form groups. 14 | // Approximately 5% of the CSS output if you include everything. 15 | 16 | @import "govuk-frontend/dist/govuk/objects"; 17 | 18 | // The components themselves - try to only include the components you 19 | // are using in your project. Approximately 70% of the CSS output if 20 | // you include everything. 21 | 22 | // @import "govuk-frontend/dist/govuk/components/accordion"; 23 | @import "govuk-frontend/dist/govuk/components/back-link"; 24 | // @import "govuk-frontend/dist/govuk/components/breadcrumbs"; 25 | @import "govuk-frontend/dist/govuk/components/button"; 26 | // @import "govuk-frontend/dist/govuk/components/character-count"; 27 | // @import "govuk-frontend/dist/govuk/components/checkboxes"; 28 | @import "govuk-frontend/dist/govuk/components/cookie-banner"; 29 | // @import "govuk-frontend/dist/govuk/components/date-input"; 30 | // @import "govuk-frontend/dist/govuk/components/details"; 31 | // @import "govuk-frontend/dist/govuk/components/error-message"; 32 | @import "govuk-frontend/dist/govuk/components/error-summary"; 33 | // @import "govuk-frontend/dist/govuk/components/exit-this-page"; 34 | // @import "govuk-frontend/dist/govuk/components/fieldset"; 35 | // @import "govuk-frontend/dist/govuk/components/file-upload"; 36 | @import "govuk-frontend/dist/govuk/components/footer"; 37 | @import "govuk-frontend/dist/govuk/components/header"; 38 | // @import "govuk-frontend/dist/govuk/components/hint"; 39 | // @import "govuk-frontend/dist/govuk/components/input"; 40 | // @import "govuk-frontend/dist/govuk/components/inset-text"; 41 | // @import "govuk-frontend/dist/govuk/components/label"; 42 | @import "govuk-frontend/dist/govuk/components/notification-banner"; 43 | // @import "govuk-frontend/dist/govuk/components/pagination"; 44 | // @import "govuk-frontend/dist/govuk/components/panel"; 45 | // @import "govuk-frontend/dist/govuk/components/password-input"; 46 | @import "govuk-frontend/dist/govuk/components/phase-banner"; 47 | @import "govuk-frontend/dist/govuk/components/radios"; 48 | // @import "govuk-frontend/dist/govuk/components/select"; 49 | @import "govuk-frontend/dist/govuk/components/service-navigation"; 50 | @import "govuk-frontend/dist/govuk/components/skip-link"; 51 | // @import "govuk-frontend/dist/govuk/components/summary-list"; 52 | @import "govuk-frontend/dist/govuk/components/table"; 53 | // @import "govuk-frontend/dist/govuk/components/tabs"; 54 | // @import "govuk-frontend/dist/govuk/components/tag"; 55 | // @import "govuk-frontend/dist/govuk/components/task-list"; 56 | // @import "govuk-frontend/dist/govuk/components/textarea"; 57 | // @import "govuk-frontend/dist/govuk/components/warning-text"; 58 | 59 | /* 60 | // Alternatively, you can import all components: 61 | @import "govuk-frontend/dist/govuk/components"; 62 | */ 63 | 64 | // Utilities, for example govuk-clearfix or govuk-visually-hidden. 65 | // Approximately 1% of the CSS output if you include everything. 66 | 67 | @import "govuk-frontend/dist/govuk/utilities"; 68 | 69 | // Overrides, used to adjust things like the amount of spacing on an 70 | // element. Override classes always include `-!-` in the class name. 71 | // Approximately 15% of the CSS output if you include everything. 72 | 73 | @import "govuk-frontend/dist/govuk/overrides"; 74 | 75 | /* 76 | // Alternatively, you can import the specific groups of overrides 77 | // you need for your project: 78 | 79 | // Display overrides - for example govuk-!-display-inline 80 | @import "govuk-frontend/dist/govuk/overrides/display"; 81 | 82 | // Spacing overrides - for example govuk-!-margin-4, govuk-!-static-padding-4 83 | @import "govuk-frontend/dist/govuk/overrides/spacing"; 84 | 85 | // Text align overrides - for example govuk-!-text-align-left 86 | @import "govuk-frontend/dist/govuk/overrides/text-align"; 87 | 88 | // Typography overrides - for example govuk-!-font-size-19, govuk-!-font-weight-bold 89 | @import "govuk-frontend/dist/govuk/overrides/typography"; 90 | 91 | // Width overrides - for example govuk-!-width-two-thirds 92 | @import "govuk-frontend/dist/govuk/overrides/width"; 93 | */ 94 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Building static assets 2 | 3 | Use [Webpack](https://webpack.js.org/) to bundle, compile and minify JavaScript, SCSS, images, and fonts, while optimising the output for performance. It uses various loaders and plugins to process files and generate the final build: 4 | 5 | - [**CSS Minimizer Webpack Plugin**](https://webpack.js.org/plugins/css-minimizer-webpack-plugin/): Uses [CSSNANO](https://cssnano.github.io/cssnano/) to minimise the CSS output, reducing file size and improving page load times. 6 | - [**PostCSS Preset Env**](https://github.com/csstools/postcss-plugins/tree/main/plugin-packs/postcss-preset-env): Uses [Autoprefixer](https://github.com/postcss/autoprefixer) to add vendor prefixes and ensure compatibility with older browsers. 7 | - [**Babel Preset Env**](https://babeljs.io/docs/babel-preset-env): Transpiles ES6+ JavaScript for cross-browser compatibility while allowing the use of modern JavaScript features. 8 | - [**Copy Webpack Plugin**](https://webpack.js.org/plugins/copy-webpack-plugin/): Automates copying images and fonts from [GOV.UK Frontend](https://frontend.design-system.service.gov.uk/) to the output directory. 9 | 10 | ## Prerequisites 11 | 12 | - A supported LTS version of [Node.js](https://nodejs.org/en) 13 | - [Node Version Manager](https://github.com/nvm-sh/nvm) (optional) 14 | - [Docker](https://www.docker.com/) (optional) 15 | 16 | ## Get started 17 | 18 | 1. Install Node, preferably using `nvm`. The version is set in the `.nvmrc` file and is typically the latest LTS release codename. 19 | 20 | ```shell 21 | nvm install 22 | ``` 23 | 24 | 2. Install the Node package dependencies from [npm](https://www.npmjs.com/): 25 | 26 | ```shell 27 | npm install 28 | ``` 29 | 30 | ## How to 31 | 32 | ### Use GOV.UK Design System components 33 | 34 | The `main.scss` file at `/web/src/scss` is highly selective about which `components` are imported above the required `base`, `core`, `objects`, `utilities` and `overrides`. Components account for around 70% of the output CSS, so should only be included if they are used in the service, in order to keep distributon file sizes small. 35 | 36 | By default, the following components are imported: 37 | 38 | - [Back link](https://design-system.service.gov.uk/components/back-link/) 39 | - [Button](https://design-system.service.gov.uk/components/button/) 40 | - [Cookie banner](https://design-system.service.gov.uk/components/cookie-banner/) 41 | - [Error summary](https://design-system.service.gov.uk/components/error-summary/) 42 | - [Footer](https://design-system.service.gov.uk/components/footer/) 43 | - [Header](https://design-system.service.gov.uk/components/header/) 44 | - [Notification banner](https://design-system.service.gov.uk/components/notification-banner/) 45 | - [Phase banner](https://design-system.service.gov.uk/components/phase-banner/) 46 | - [Radios](https://design-system.service.gov.uk/components/radios/) 47 | - [Service navigation](https://design-system.service.gov.uk/components/service-navigation/) 48 | - [Skip link](https://design-system.service.gov.uk/components/skip-link/) 49 | - [Table](https://design-system.service.gov.uk/components/table/) 50 | 51 | Simply uncomment any other components in `main.scss` that you need to use. 52 | 53 | The same approach applies to JS; the `main.mjs` file at `/src/js` only imports JS for the components being used: 54 | 55 | - Button 56 | - Error summary 57 | - Notification banner 58 | - Radios 59 | - Service navigation 60 | - Skip link 61 | 62 | > **Note**: The JS for the Header component is not needed when using the newer [Service navigation](https://design-system.service.gov.uk/components/service-navigation/) component alongside it. 63 | 64 | For comparison (using GOV.UK Frontend v5.9.0): 65 | 66 | | Asset | Size (KB) | 67 | | --------------- | --------- | 68 | | Precompiled CSS | 127 | 69 | | Selective CSS | 75 (-41%) | 70 | | Precompiled JS | 49 | 71 | | Selective JS | 11 (-78%) | 72 | 73 | ### Format source code 74 | 75 | Use [Prettier](https://prettier.io/), an opinionated code formatter, for consistency. 76 | 77 | To check formatting (without changing): 78 | 79 | ```shell 80 | npm run format:check 81 | ``` 82 | 83 | To reformat files: 84 | 85 | ```shell 86 | npm run format:fix 87 | ``` 88 | 89 | ### Lint source code 90 | 91 | Use [ESLint](https://eslint.org/) to statically analyse your code to quickly find problems. 92 | 93 | To check for issues: 94 | 95 | ```shell 96 | npm run lint:check 97 | ``` 98 | 99 | To attempt to automatically fix issues: 100 | 101 | ```shell 102 | npm run lint:fix 103 | ``` 104 | 105 | ### Build assets 106 | 107 | Use [Webpack](https://webpack.js.org/) loaders and plugins to output CSS, JS, fonts and images to `./dist`: 108 | 109 | ```shell 110 | npm run build 111 | ``` 112 | 113 | ### Watch changes 114 | 115 | Rebuild distribution assets automatically when source is changed: 116 | 117 | ```shell 118 | npm run watch 119 | ``` 120 | 121 | ### Upgrade dependencies 122 | 123 | Use [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) to upgrade Node package dependencies (such as [govuk-frontend](https://www.npmjs.com/package/govuk-frontend)): 124 | 125 | ```shell 126 | npm run upgrade:latest 127 | ``` 128 | 129 | If you want to be more cautious you can apply only minor or patch level upgrades: 130 | 131 | ```shell 132 | npm run upgrade:minor 133 | ``` 134 | 135 | ```shell 136 | npm run upgrade:patch 137 | ``` 138 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | {%- extends 'govuk_frontend_jinja/template.html' -%} 2 | 3 | {%- from 'govuk_frontend_jinja/components/cookie-banner/macro.html' import govukCookieBanner-%} 4 | {%- from 'govuk_frontend_jinja/components/error-summary/macro.html' import govukErrorSummary-%} 5 | {%- from 'govuk_frontend_jinja/components/notification-banner/macro.html' import govukNotificationBanner -%} 6 | {%- from 'govuk_frontend_jinja/components/phase-banner/macro.html' import govukPhaseBanner -%} 7 | {%- from 'govuk_frontend_jinja/components/service-navigation/macro.html' import govukServiceNavigation -%} 8 | 9 | {%- set assetPath = "/static/assets" -%} 10 | 11 | {%- block pageTitle -%}{{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 12 | 13 | {%- block head -%} 14 | <meta name="description" content="{{config['SERVICE_NAME']}}"> 15 | <meta name="keywords" content=""> 16 | <meta name="author" content="{{config['DEPARTMENT_NAME']}}"> 17 | <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='main.min.css') }}" /> 18 | {%- endblock -%} 19 | 20 | {%- block bodyStart -%} 21 | {%- if "functional" not in request.cookies or "analytics" not in request.cookies -%} 22 | {%- set html -%} 23 | <p class="govuk-body">We use some essential cookies to make this service work.</p> 24 | <p class="govuk-body">We’d like to set additional cookies so we can remember your settings, understand how people use the service and make improvements.</p> 25 | {%- endset -%} 26 | 27 | {%- set acceptHtml -%} 28 | <p class="govuk-body">You’ve accepted additional cookies. You can <a class="govuk-link" href="{{ url_for('main.cookies') }}">change your cookie settings</a> at any time.</p> 29 | {%- endset -%} 30 | 31 | {%- set rejectHtml -%} 32 | <p class="govuk-body">You’ve rejected additional cookies. You can <a class="govuk-link" href="{{ url_for('main.cookies') }}">change your cookie settings</a> at any time.</p> 33 | {%- endset -%} 34 | 35 | {{ govukCookieBanner({ 36 | 'ariaLabel': "Cookies on " ~ config['SERVICE_NAME'], 37 | 'attributes': { 38 | 'id': "cookie-banner" 39 | }, 40 | 'messages': [ 41 | { 42 | 'attributes': { 43 | 'id': "default-message" 44 | }, 45 | 'headingText': "Cookies on " ~ config['SERVICE_NAME'], 46 | 'html': html, 47 | 'actions': [ 48 | { 49 | 'attributes': { 50 | 'id': "accept-cookies" 51 | }, 52 | 'text': "Accept additional cookies", 53 | 'type': "button", 54 | 'name': "cookies", 55 | 'value': "accept" 56 | }, 57 | { 58 | 'attributes': { 59 | 'id': "reject-cookies" 60 | }, 61 | 'text': "Reject additional cookies", 62 | 'type': "button", 63 | 'name': "cookies", 64 | 'value': "reject" 65 | }, 66 | { 67 | 'text': "View cookies", 68 | 'href': url_for('main.cookies') 69 | } 70 | ] 71 | }, 72 | { 73 | 'attributes': { 74 | 'id': "accepted-message" 75 | }, 76 | 'html': acceptHtml, 77 | 'role': "alert", 78 | 'hidden': true, 79 | 'actions': [ 80 | { 81 | 'attributes': { 82 | 'id': "accepted-hide" 83 | }, 84 | 'text': "Hide this message" 85 | } 86 | ] 87 | }, 88 | { 89 | 'attributes': { 90 | 'id': "rejected-message" 91 | }, 92 | 'html': rejectHtml, 93 | 'role': "alert", 94 | 'hidden': true, 95 | 'actions': [ 96 | { 97 | 'attributes': { 98 | 'id': "rejected-hide" 99 | }, 100 | 'text': "Hide this message" 101 | } 102 | ] 103 | } 104 | ] 105 | }) }} 106 | {%- endif -%} 107 | {%- endblock -%} 108 | 109 | {%- block header -%} 110 | {{ govukHeader({}) }} 111 | {{ govukServiceNavigation({ 112 | 'serviceName': config['SERVICE_NAME'], 113 | 'serviceUrl': url_for('main.index'), 114 | 'navigation': [ 115 | { 116 | 'href': "#", 117 | 'text': "Navigation item 1" 118 | }, 119 | { 120 | 'href': "#", 121 | 'text': "Navigation item 2", 122 | 'active': true 123 | }, 124 | { 125 | 'href': "#", 126 | 'text': "Navigation item 3" 127 | } 128 | ] 129 | }) }} 130 | {%- endblock -%} 131 | 132 | {%- block beforeContent -%} 133 | {{ govukPhaseBanner({ 134 | 'tag': { 135 | 'text': config['SERVICE_PHASE'] 136 | }, 137 | 'html': 'This is a new service – your <a class="govuk-link" href="mailto:' + config['CONTACT_EMAIL'] + '">feedback</a> will help us to improve it.' 138 | }) }} 139 | {%- endblock -%} 140 | 141 | {%- block content -%} 142 | {%- if form and form.errors -%} 143 | {{ govukErrorSummary(wtforms_errors(form)) }} 144 | {%- endif -%} 145 | 146 | {%- with messages = get_flashed_messages(with_categories=true) -%} 147 | {%- if messages -%} 148 | {%- for category, message in messages -%} 149 | {{ govukNotificationBanner({'type': category, 'html': message}) }} 150 | {%- endfor -%} 151 | {%- endif -%} 152 | {%- endwith -%} 153 | {%- endblock -%} 154 | 155 | {%- block footer -%} 156 | {{ govukFooter({ 157 | 'meta': { 158 | 'items': [ 159 | { 160 | 'href': url_for('main.accessibility'), 161 | 'text': "Accessibility" 162 | }, 163 | { 164 | 'href': url_for('main.cookies'), 165 | 'text': "Cookies" 166 | }, 167 | { 168 | 'href': url_for('main.privacy'), 169 | 'text': "Privacy" 170 | } 171 | ], 172 | 'html': 'Built by <a href="' + config['DEPARTMENT_URL'] +'" class="govuk-footer__link">' + config['DEPARTMENT_NAME'] + '</a>' 173 | } 174 | }) }} 175 | {%- endblock -%} 176 | 177 | {%- block bodyEnd -%} 178 | <script type="module" src="{{ url_for('static', filename='main.min.js') }}"></script> 179 | {%- endblock -%} -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/git,node,sass,linux,macos,windows,jetbrains+all,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=git,node,sass,linux,macos,windows,jetbrains+all,visualstudiocode 3 | 4 | ### Git ### 5 | # Created by git for backups. To disable backups in Git: 6 | # $ git config --global mergetool.keepBackup false 7 | *.orig 8 | 9 | # Created by git when using merge tools for conflicts 10 | *.BACKUP.* 11 | *.BASE.* 12 | *.LOCAL.* 13 | *.REMOTE.* 14 | *_BACKUP_*.txt 15 | *_BASE_*.txt 16 | *_LOCAL_*.txt 17 | *_REMOTE_*.txt 18 | 19 | ### JetBrains+all ### 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 21 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 22 | 23 | # User-specific stuff 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/usage.statistics.xml 27 | .idea/**/dictionaries 28 | .idea/**/shelf 29 | 30 | # AWS User-specific 31 | .idea/**/aws.xml 32 | 33 | # Generated files 34 | .idea/**/contentModel.xml 35 | 36 | # Sensitive or high-churn files 37 | .idea/**/dataSources/ 38 | .idea/**/dataSources.ids 39 | .idea/**/dataSources.local.xml 40 | .idea/**/sqlDataSources.xml 41 | .idea/**/dynamic.xml 42 | .idea/**/uiDesigner.xml 43 | .idea/**/dbnavigator.xml 44 | 45 | # Gradle 46 | .idea/**/gradle.xml 47 | .idea/**/libraries 48 | 49 | # Gradle and Maven with auto-import 50 | # When using Gradle or Maven with auto-import, you should exclude module files, 51 | # since they will be recreated, and may cause churn. Uncomment if using 52 | # auto-import. 53 | # .idea/artifacts 54 | # .idea/compiler.xml 55 | # .idea/jarRepositories.xml 56 | # .idea/modules.xml 57 | # .idea/*.iml 58 | # .idea/modules 59 | # *.iml 60 | # *.ipr 61 | 62 | # CMake 63 | cmake-build-*/ 64 | 65 | # Mongo Explorer plugin 66 | .idea/**/mongoSettings.xml 67 | 68 | # File-based project format 69 | *.iws 70 | 71 | # IntelliJ 72 | out/ 73 | 74 | # mpeltonen/sbt-idea plugin 75 | .idea_modules/ 76 | 77 | # JIRA plugin 78 | atlassian-ide-plugin.xml 79 | 80 | # Cursive Clojure plugin 81 | .idea/replstate.xml 82 | 83 | # SonarLint plugin 84 | .idea/sonarlint/ 85 | 86 | # Crashlytics plugin (for Android Studio and IntelliJ) 87 | com_crashlytics_export_strings.xml 88 | crashlytics.properties 89 | crashlytics-build.properties 90 | fabric.properties 91 | 92 | # Editor-based Rest Client 93 | .idea/httpRequests 94 | 95 | # Android studio 3.1+ serialized cache file 96 | .idea/caches/build_file_checksums.ser 97 | 98 | ### JetBrains+all Patch ### 99 | # Ignore everything but code style settings and run configurations 100 | # that are supposed to be shared within teams. 101 | 102 | .idea/* 103 | 104 | !.idea/codeStyles 105 | !.idea/runConfigurations 106 | 107 | ### Linux ### 108 | *~ 109 | 110 | # temporary files which can be created if a process still has a handle open of a deleted file 111 | .fuse_hidden* 112 | 113 | # KDE directory preferences 114 | .directory 115 | 116 | # Linux trash folder which might appear on any partition or disk 117 | .Trash-* 118 | 119 | # .nfs files are created when an open file is removed but is still being accessed 120 | .nfs* 121 | 122 | ### macOS ### 123 | # General 124 | .DS_Store 125 | .AppleDouble 126 | .LSOverride 127 | 128 | # Icon must end with two \r 129 | Icon 130 | 131 | 132 | # Thumbnails 133 | ._* 134 | 135 | # Files that might appear in the root of a volume 136 | .DocumentRevisions-V100 137 | .fseventsd 138 | .Spotlight-V100 139 | .TemporaryItems 140 | .Trashes 141 | .VolumeIcon.icns 142 | .com.apple.timemachine.donotpresent 143 | 144 | # Directories potentially created on remote AFP share 145 | .AppleDB 146 | .AppleDesktop 147 | Network Trash Folder 148 | Temporary Items 149 | .apdisk 150 | 151 | ### macOS Patch ### 152 | # iCloud generated files 153 | *.icloud 154 | 155 | ### Node ### 156 | # Logs 157 | logs 158 | *.log 159 | npm-debug.log* 160 | yarn-debug.log* 161 | yarn-error.log* 162 | lerna-debug.log* 163 | .pnpm-debug.log* 164 | 165 | # Diagnostic reports (https://nodejs.org/api/report.html) 166 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 167 | 168 | # Runtime data 169 | pids 170 | *.pid 171 | *.seed 172 | *.pid.lock 173 | 174 | # Directory for instrumented libs generated by jscoverage/JSCover 175 | lib-cov 176 | 177 | # Coverage directory used by tools like istanbul 178 | coverage 179 | *.lcov 180 | 181 | # nyc test coverage 182 | .nyc_output 183 | 184 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 185 | .grunt 186 | 187 | # Bower dependency directory (https://bower.io/) 188 | bower_components 189 | 190 | # node-waf configuration 191 | .lock-wscript 192 | 193 | # Compiled binary addons (https://nodejs.org/api/addons.html) 194 | build/Release 195 | 196 | # Dependency directories 197 | node_modules/ 198 | jspm_packages/ 199 | 200 | # Snowpack dependency directory (https://snowpack.dev/) 201 | web_modules/ 202 | 203 | # TypeScript cache 204 | *.tsbuildinfo 205 | 206 | # Optional npm cache directory 207 | .npm 208 | 209 | # Optional eslint cache 210 | .eslintcache 211 | 212 | # Optional stylelint cache 213 | .stylelintcache 214 | 215 | # Microbundle cache 216 | .rpt2_cache/ 217 | .rts2_cache_cjs/ 218 | .rts2_cache_es/ 219 | .rts2_cache_umd/ 220 | 221 | # Optional REPL history 222 | .node_repl_history 223 | 224 | # Output of 'npm pack' 225 | *.tgz 226 | 227 | # Yarn Integrity file 228 | .yarn-integrity 229 | 230 | # dotenv environment variable files 231 | .env 232 | .env.development.local 233 | .env.test.local 234 | .env.production.local 235 | .env.local 236 | 237 | # parcel-bundler cache (https://parceljs.org/) 238 | .cache 239 | .parcel-cache 240 | 241 | # Next.js build output 242 | .next 243 | out 244 | 245 | # Nuxt.js build / generate output 246 | .nuxt 247 | dist 248 | 249 | # Gatsby files 250 | .cache/ 251 | # Comment in the public line in if your project uses Gatsby and not Next.js 252 | # https://nextjs.org/blog/next-9-1#public-directory-support 253 | # public 254 | 255 | # vuepress build output 256 | .vuepress/dist 257 | 258 | # vuepress v2.x temp and cache directory 259 | .temp 260 | 261 | # Docusaurus cache and generated files 262 | .docusaurus 263 | 264 | # Serverless directories 265 | .serverless/ 266 | 267 | # FuseBox cache 268 | .fusebox/ 269 | 270 | # DynamoDB Local files 271 | .dynamodb/ 272 | 273 | # TernJS port file 274 | .tern-port 275 | 276 | # Stores VSCode versions used for testing VSCode extensions 277 | .vscode-test 278 | 279 | # yarn v2 280 | .yarn/cache 281 | .yarn/unplugged 282 | .yarn/build-state.yml 283 | .yarn/install-state.gz 284 | .pnp.* 285 | 286 | ### Node Patch ### 287 | # Serverless Webpack directories 288 | .webpack/ 289 | 290 | # Optional stylelint cache 291 | 292 | # SvelteKit build / generate output 293 | .svelte-kit 294 | 295 | ### Sass ### 296 | .sass-cache/ 297 | *.css.map 298 | *.sass.map 299 | *.scss.map 300 | 301 | ### VisualStudioCode ### 302 | .vscode/* 303 | !.vscode/settings.json 304 | !.vscode/tasks.json 305 | !.vscode/launch.json 306 | !.vscode/extensions.json 307 | !.vscode/*.code-snippets 308 | 309 | # Local History for Visual Studio Code 310 | .history/ 311 | 312 | # Built Visual Studio Code Extensions 313 | *.vsix 314 | 315 | ### VisualStudioCode Patch ### 316 | # Ignore all local history of files 317 | .history 318 | .ionide 319 | 320 | ### Windows ### 321 | # Windows thumbnail cache files 322 | Thumbs.db 323 | Thumbs.db:encryptable 324 | ehthumbs.db 325 | ehthumbs_vista.db 326 | 327 | # Dump file 328 | *.stackdump 329 | 330 | # Folder config file 331 | [Dd]esktop.ini 332 | 333 | # Recycle Bin used on file shares 334 | $RECYCLE.BIN/ 335 | 336 | # Windows Installer files 337 | *.cab 338 | *.msi 339 | *.msix 340 | *.msm 341 | *.msp 342 | 343 | # Windows shortcuts 344 | *.lnk 345 | 346 | # End of https://www.toptal.com/developers/gitignore/api/git,node,sass,linux,macos,windows,jetbrains+all,visualstudiocode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Static Badge](https://img.shields.io/badge/GOV.UK%20Frontend-v5.13.0-blue) 2 | 3 | # GOV.UK Frontend - Flask App Template 4 | 5 | Start building **accessible**, **secure**, **production-ready** and **maintainable** GOV.UK-style services, fast. 6 | 7 | A [Flask](https://flask.palletsprojects.com) application integrating the [GOV.UK Design System](https://design-system.service.gov.uk/) with a realistic, containerised stack. 8 | 9 | > **GOV.UK Frontend Flask App Template is a [community tool](https://design-system.service.gov.uk/community/resources-and-tools/) of the [GOV.UK Design System](https://design-system.service.gov.uk/). The Design System team is not responsible for it and cannot support you with using it. Contact the [maintainers](#contributors) directly if you need [help](#support) or you want to request a feature.** 10 | 11 | ## Highlights 12 | 13 | - **GOV.UK components built in** – Accessible [Jinja templates](https://github.com/LandRegistry/govuk-frontend-jinja) and [WTForms helpers](https://github.com/LandRegistry/govuk-frontend-wtf) for compliant UI and forms. 14 | - **Secure Flask foundation** – HTTPS, CSRF, CSP, rate limits, [SQLAlchemy](https://www.sqlalchemy.org/) and migrations ready to go. 15 | - **Containerised by default** – [Nginx](https://nginx.org/en/) , [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/) and [Node](https://nodejs.org/en) pipeline managed via [Docker Compose](https://docs.docker.com/compose/). 16 | - **Fast, lean builds** – Multi-stage Dockerfiles, wheel caching, non-root runtime, and CI via [GitHub Actions](https://github.com/features/actions). 17 | - **Compliance-ready pages** – 404/500 errors, cookie banner, accessibility statement and privacy notice included. 18 | - **Developer-first setup** – Example blueprints, templates, macros, and GOV.UK-style flash messages for instant feedback. 19 | 20 | ## Security 21 | 22 | Secure by default with hardened containers, strong HTTP headers and built-in rate limiting. 23 | 24 | - Applies strict CSP, HSTS, and other security headers. 25 | - CSRF protection via Flask-WTF, with safe error handling. 26 | - Rate limiting backed by Redis using Flask-Limiter. 27 | - Non-root containers with read-only filesystem for runtime services. 28 | - Secrets and credentials injected via environment variables (no in-repo secrets). 29 | - Dependency scanning and Python version pinning via CI workflows. 30 | 31 | ## Performance 32 | 33 | Optimised for speed and reliability through caching, minimal layers and lean builds. 34 | 35 | - Multi-stage Docker builds minimise image size and attack surface. 36 | - Static assets compiled once and cached efficiently. 37 | - Connection pooling for SQLAlchemy database access. 38 | - Redis caching support for transient or computed data. 39 | - Nginx configured for compression and cache control. 40 | - CI validates image build times and wheel caching efficiency. 41 | 42 | ## Developer Experience 43 | 44 | Built to feel frictionless for rapid iteration, testing and deployment. 45 | 46 | - Works identically across local and production environments. 47 | - Uses docker compose watch for hot reloads of Python and static assets. 48 | - Includes blueprints, forms, templates and example routes to extend quickly. 49 | - Built-in error pages, logging and debug toolbar (development mode). 50 | - Extensive comments and .env.example for easy onboarding. 51 | - CI workflows for linting, tests, builds and security scans. 52 | 53 | ## Requirements 54 | 55 | - Docker (Engine & Compose) 56 | 57 | ## Quick start 58 | 59 | ### 1. Create a new repository 60 | 61 | [Create a new repository](https://github.com/LandRegistry/govuk-frontend-flask/generate) using this template, with the same directory structure and files. Then clone a local copy of your newly created repository. 62 | 63 | ### 2. Configure environment 64 | 65 | Create a `.env` file in the root of the repo and enter your specific config based on this example: 66 | 67 | ```dotenv 68 | CONTACT_EMAIL=[contact email] 69 | CONTACT_PHONE=[contact phone] 70 | DEPARTMENT_NAME=[name of department] 71 | DEPARTMENT_URL=[url of department] 72 | POSTGRES_DB=db 73 | POSTGRES_HOST=db 74 | POSTGRES_PASSWORD=db_password 75 | POSTGRES_PORT=5432 76 | POSTGRES_USER=db_user 77 | REDIS_HOST=cache 78 | REDIS_PORT=6379 79 | SECRET_KEY=[see below] 80 | SERVICE_NAME=[name of service] 81 | SERVICE_PHASE=[phase] 82 | SERVICE_URL=[url of service] 83 | ``` 84 | 85 | You **must** set a new `SECRET_KEY`, which is used to securely sign the session cookie and CSRF tokens. It should be a long random `bytes` or `str`. You can use the output of this Python command to generate a new key: 86 | 87 | ```shell 88 | python -c 'import secrets; print(secrets.token_hex())' 89 | ``` 90 | 91 | ### 3. Start the stack 92 | 93 | ```shell 94 | docker compose up --build 95 | ``` 96 | 97 | Visit <https://localhost/> and accept the browser’s security warning. 98 | 99 | Hot reloading is supported via: 100 | 101 | ```shell 102 | docker compose watch 103 | ``` 104 | 105 | > **Note**: `docker compose watch` requires Docker Compose v2.22 or newer. 106 | 107 | ## Testing 108 | 109 | Run unit tests and measure coverage locally: 110 | 111 | ```shell 112 | python -m pytest --cov=app --cov-report=term-missing --cov-branch 113 | ``` 114 | 115 | ## Environment 116 | 117 | | Service | Role | Container | Port exposed | 118 | | ---------- | --------------------------------- | --------- | ---------------- | 119 | | Nginx | Reverse proxy + HTTPS termination | `web` | 443 (HTTPS) / 80 | 120 | | Flask | Web framework | `app` | 5000 | 121 | | PostgreSQL | Relational database | `db` | 5432 | 122 | | Redis | Caching + rate limiting backend | `cache` | 6379 | 123 | 124 | ## Architecture 125 | 126 | ### Container stack 127 | 128 | This project uses Docker Compose to provision containers: 129 | 130 | ```mermaid 131 | flowchart TB 132 | compose(compose.yml) 133 | nginx(nginx:stable-alpine) 134 | node(node:kyrpton-alpine) 135 | postgres(postgres:18-alpine) 136 | python(python:3.14-slim) 137 | redis(redis:7-alpine) 138 | 139 | compose -- Creates --> App & Cache & Web & Database 140 | App -- Depends on --> Cache & Database 141 | Web -- Depends on --> App 142 | 143 | subgraph Web 144 | direction TB 145 | node -- COPY /dist /static --> nginx 146 | end 147 | 148 | subgraph App 149 | python 150 | end 151 | 152 | subgraph Database 153 | postgres 154 | end 155 | 156 | subgraph Cache 157 | redis 158 | end 159 | ``` 160 | 161 | ### Request flow 162 | 163 | ```mermaid 164 | flowchart TB 165 | browser([Browser]) 166 | db@{ shape: cyl, label: "PostgreSQL" } 167 | flask(Gunicorn/Flask) 168 | nginx(NGINX) 169 | redis(Redis) 170 | static@{ shape: lin-cyl, label: "Static files" } 171 | 172 | browser -- https:443 --> nginx -- http:5000 --> flask -- postgres:5432 --> db 173 | flask -- redis:6379 --> redis 174 | 175 | subgraph Web 176 | nginx -- Read --> static 177 | end 178 | 179 | subgraph App 180 | flask 181 | end 182 | 183 | subgraph Database 184 | db 185 | end 186 | 187 | subgraph Cache 188 | redis 189 | end 190 | ``` 191 | 192 | ## Maintainers 193 | 194 | - [Matt Shaw](https://github.com/matthew-shaw) - Principal Software Developer at HM Land Registry 195 | 196 | ## Support 197 | 198 | This software is provided _"as-is"_ without warranty. Support is provided on a _"best endeavours"_ basis by the maintainers and open source community. 199 | 200 | For questions or suggestions, reach out to the maintainers listed [above](#maintainers) and the community of people using this project in the [#govuk-design-system](https://ukgovernmentdigital.slack.com/archives/C6DMEH5R6) Slack channel. 201 | 202 | Otherwise, please see the [contribution guidelines](CONTRIBUTING.md) for how to raise a bug report or feature request. 203 | -------------------------------------------------------------------------------- /app/templates/main/accessibility.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | 3 | {%- from 'govuk_frontend_jinja/components/back-link/macro.html' import govukBackLink -%} 4 | 5 | {%- block pageTitle -%}Accessibility statement – {{config['SERVICE_NAME']}} – GOV.UK{%- endblock -%} 6 | 7 | {%- block beforeContent -%} 8 | {{ super() }} 9 | {{ govukBackLink({ 10 | 'text': "Back", 11 | 'href': url_for('main.index') 12 | }) }} 13 | {%- endblock -%} 14 | 15 | {%- block content -%} 16 | <div class="govuk-grid-row"> 17 | <div class="govuk-grid-column-two-thirds"> 18 | {{ super() }} 19 | <h1 class="govuk-heading-xl">Accessibility statement for {{config['SERVICE_NAME']}}</h1> 20 | 21 | <p class="govuk-body">This accessibility statement applies to {{config['SERVICE_URL']}}.</p> 22 | 23 | <p class="govuk-body">This website is run by {{config['DEPARTMENT_NAME']}}. We want as many people as possible to be able 24 | to use this website. For example, that means you should be able to:</p> 25 | <ul class="govuk-list govuk-list--bullet"> 26 | <li>change colours, contrast levels and fonts</li> 27 | <li>zoom in up to 300% without the text spilling off the screen</li> 28 | <li>navigate most of the website using just a keyboard</li> 29 | <li>navigate most of the website using speech recognition software</li> 30 | <li>listen to most of the website using a screen reader (including the most recent versions of JAWS, NVDA and 31 | VoiceOver)</li> 32 | </ul> 33 | <p class="govuk-body">We’ve also made the website text as simple as possible to understand.</p> 34 | <p class="govuk-body"><a class="govuk-link" href="https://mcmw.abilitynet.org.uk/">AbilityNet</a> has advice on making your device easier to use if you have a disability.</p> 35 | 36 | <h3 class="govuk-heading-m">How accessible this website is</h3> 37 | 38 | <p class="govuk-body">We know some parts of this website are not fully accessible:</p> 39 | <ul class="govuk-list govuk-list--bullet"> 40 | <li>the text will not reflow in a single column when you change the size of the browser window</li> 41 | <li>you cannot modify the line height or spacing of text</li> 42 | <li>most older PDF documents are not fully accessible to screen reader software</li> 43 | <li>live video streams do not have captions</li> 44 | <li>some of our online forms are difficult to navigate using just a keyboard</li> 45 | <li>you cannot skip to the main content when using a screen reader</li> 46 | <li>there’s a limit to how far you can magnify the map on our ‘contact us’ page</li> 47 | </ul> 48 | 49 | <h3 class="govuk-heading-m">Feedback and contact information</h3> 50 | 51 | <p class="govuk-body">If you need information on this website in a different format like accessible PDF, large 52 | print, easy read, audio recording or braille:</p> 53 | <ul class="govuk-list govuk-list--bullet"> 54 | <li>email {{config['CONTACT_EMAIL']}}</li> 55 | <li>call {{config['CONTACT_PHONE']}}</li> 56 | <li>[add any other contact details]</li> 57 | </ul> 58 | <p class="govuk-body">We’ll consider your request and get back to you in [number] days.</p> 59 | <p class="govuk-body">If you cannot view the map on our ‘contact us’ page, call or email us [add link to contact 60 | details page] for directions.</p> 61 | 62 | <h3 class="govuk-heading-m">Reporting accessibility problems with this website</h3> 63 | 64 | <p class="govuk-body">We’re always looking to improve the accessibility of this website. If you find any problems 65 | not listed on this page or think we’re not meeting accessibility requirements, contact: [provide both details of 66 | how to report these issues to your organisation, and contact details for the unit or person responsible for 67 | dealing with these reports].</p> 68 | 69 | <h3 class="govuk-heading-m">Enforcement procedure</h3> 70 | 71 | <p class="govuk-body">The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector 72 | Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 (the ‘accessibility 73 | regulations’). If you’re not happy with how we respond to your complaint, <a class="govuk-link" 74 | href="https://www.equalityadvisoryservice.com/">contact the Equality Advisory and 75 | Support Service (EASS)</a>.</p> 76 | 77 | <h2 class="govuk-heading-l">Contacting us by phone or visiting us in person</h2> 78 | 79 | <p class="govuk-body">We provide a text relay service for people who are D/deaf, hearing impaired or have a speech 80 | impediment.</p> 81 | <p class="govuk-body">Our offices have audio induction loops, or if you contact us before your visit we can arrange 82 | a British Sign Language (BSL) interpreter.</p> 83 | <p class="govuk-body">Find out how to contact us [add link to contact details page].</p> 84 | 85 | <h2 class="govuk-heading-l">Technical information about this website’s accessibility</h2> 86 | 87 | <p class="govuk-body">{{config['DEPARTMENT_NAME']}} is committed to making its website accessible, in accordance with the 88 | Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018.</p> 89 | 90 | <h3 class="govuk-heading-m">Compliance status</h3> 91 | 92 | <p class="govuk-body">This website is fully compliant with the <a href="https://www.w3.org/TR/WCAG21/" class="govuk-link">Web Content Accessibility Guidelines version 2.1</a> AA 93 | standard.</p> 94 | <p class="govuk-body">This website is partially compliant with the <a href="https://www.w3.org/TR/WCAG21/" class="govuk-link">Web Content Accessibility Guidelines version 2.1</a> 95 | AA standard, due to [insert one of the following: ‘the non-compliances’, ‘the exemptions’ or ‘the non-compliances 96 | and exemptions’] listed below.</p> 97 | <p class="govuk-body">This website is not compliant with the <a href="https://www.w3.org/TR/WCAG21/" class="govuk-link">Web Content Accessibility Guidelines version 2.1</a> AA 98 | standard. The [insert one of the following: ‘non-compliances’, ‘exemptions’ or ‘non-compliances and exemptions’] 99 | are listed below.</p> 100 | 101 | <h2 class="govuk-heading-l">Non-accessible content</h2> 102 | 103 | <p class="govuk-body">The content listed below is non-accessible for the following reasons.</p> 104 | 105 | <h3 class="govuk-heading-m">Non-compliance with the accessibility regulations</h3> 106 | 107 | <p class="govuk-body">Some images do not have a text alternative, so people using a screen reader cannot access the 108 | information. This fails WCAG 2.1 success criterion 1.1.1 (non-text content).</p> 109 | 110 | <p class="govuk-body">We plan to add text alternatives for all images by September 2020. When we publish new content 111 | we’ll make sure our use of images meets accessibility standards.</p> 112 | 113 | <h3 class="govuk-heading-m">Disproportionate burden</h3> 114 | 115 | <h3 class="govuk-heading-s">Navigation and accessing information</h3> 116 | 117 | <p class="govuk-body">There’s no way to skip the repeated content in the page header (for example, a ‘skip to main 118 | content’ option).</p> 119 | 120 | <p class="govuk-body">It’s not always possible to change the device orientation from horizontal to vertical without 121 | making it more difficult to view the content.</p> 122 | 123 | <p class="govuk-body">It’s not possible for users to change text size without some of the content overlapping.</p> 124 | 125 | <h3 class="govuk-heading-s">Interactive tools and transactions</h3> 126 | 127 | <p class="govuk-body">Some of our interactive forms are difficult to navigate using a keyboard. For example, because 128 | some form controls are missing a ‘label’ tag.</p> 129 | 130 | <p class="govuk-body">Our forms are built and hosted through third party software and ‘skinned’ to look like our 131 | website.</p> 132 | 133 | <p class="govuk-body">We’ve assessed the cost of fixing the issues with navigation and accessing information, and 134 | with interactive tools and transactions. We believe that doing so now would be a <a href="http://www.legislation.gov.uk/uksi/2018/952/regulation/7/made" class="govuk-link">disproportionate burden</a> within 135 | the meaning of the accessibility regulations. We will make another assessment when the supplier contract is up for 136 | renewal, likely to be in [rough timing].</p> 137 | 138 | <h3 class="govuk-heading-m">Content that’s not within the scope of the accessibility regulations</h3> 139 | 140 | <h3 class="govuk-heading-s">PDFs and other documents</h3> 141 | 142 | <p class="govuk-body">Some of our PDFs and Word documents are essential to providing our services. For example, we 143 | have PDFs with information on how users can access our services, and forms published as Word documents. By 144 | September 2020, we plan to either fix these or replace them with accessible HTML pages.</p> 145 | 146 | <p class="govuk-body">The accessibility regulations <a href="http://www.legislation.gov.uk/uksi/2018/952/regulation/4/made" class="govuk-link">do not require us to fix PDFs or other documents published 147 | before 23 September 2018</a> if they’re not essential to providing our services. For example, we do not plan to fix 148 | [example of non-essential document].</p> 149 | 150 | <p class="govuk-body">Any new PDFs or Word documents we publish will meet accessibility standards.</p> 151 | 152 | <h3 class="govuk-heading-s">Live video</h3> 153 | 154 | <p class="govuk-body">We do not plan to add captions to live video streams because live video is <a href="http://www.legislation.gov.uk/uksi/2018/952/regulation/4/made" class="govuk-link">exempt from meeting 155 | the accessibility regulations</a>.</p> 156 | 157 | <h2 class="govuk-heading-l">What we’re doing to improve accessibility</h2> 158 | 159 | <p class="govuk-body">Our accessibility roadmap [add link to roadmap] shows how and when we plan to improve 160 | accessibility on this website.</p> 161 | 162 | <h2 class="govuk-heading-l">Preparation of this accessibility statement</h2> 163 | 164 | <p class="govuk-body">This statement was prepared on [date when it was first published]. It was last reviewed on 165 | [date when it was last reviewed].</p> 166 | 167 | <p class="govuk-body">This website was last tested on [date]. The test was carried out by [add name of organisation 168 | that carried out test, or indicate that you did your own testing].</p> 169 | 170 | <p class="govuk-body">We used this approach to deciding on a sample of pages to test [add link to explanation of how 171 | you decided which pages to test].</p> 172 | 173 | <p class="govuk-body">You can read the full accessibility test report [add link to report].</p> 174 | </div> 175 | </div> 176 | {%- endblock -%} 177 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/git,flask,linux,macos,python,windows,jetbrains+all,visualstudiocode,venv 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=git,flask,linux,macos,python,windows,jetbrains+all,visualstudiocode,venv 3 | 4 | ### Flask ### 5 | instance/* 6 | !instance/.gitignore 7 | .webassets-cache 8 | .env 9 | 10 | ### Flask.Python Stack ### 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | cover/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | .pybuilder/ 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | # For a library or package, you might want to ignore these files since the code is 96 | # intended to run in multiple environments; otherwise, check them in: 97 | # .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | 113 | # pdm 114 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 115 | #pdm.lock 116 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 117 | # in version control. 118 | # https://pdm.fming.dev/#use-with-ide 119 | .pdm.toml 120 | 121 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 122 | __pypackages__/ 123 | 124 | # Celery stuff 125 | celerybeat-schedule 126 | celerybeat.pid 127 | 128 | # SageMath parsed files 129 | *.sage.py 130 | 131 | # Environments 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | ### Git ### 171 | # Created by git for backups. To disable backups in Git: 172 | # $ git config --global mergetool.keepBackup false 173 | *.orig 174 | 175 | # Created by git when using merge tools for conflicts 176 | *.BACKUP.* 177 | *.BASE.* 178 | *.LOCAL.* 179 | *.REMOTE.* 180 | *_BACKUP_*.txt 181 | *_BASE_*.txt 182 | *_LOCAL_*.txt 183 | *_REMOTE_*.txt 184 | 185 | ### JetBrains+all ### 186 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 187 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 188 | 189 | # User-specific stuff 190 | .idea/**/workspace.xml 191 | .idea/**/tasks.xml 192 | .idea/**/usage.statistics.xml 193 | .idea/**/dictionaries 194 | .idea/**/shelf 195 | 196 | # AWS User-specific 197 | .idea/**/aws.xml 198 | 199 | # Generated files 200 | .idea/**/contentModel.xml 201 | 202 | # Sensitive or high-churn files 203 | .idea/**/dataSources/ 204 | .idea/**/dataSources.ids 205 | .idea/**/dataSources.local.xml 206 | .idea/**/sqlDataSources.xml 207 | .idea/**/dynamic.xml 208 | .idea/**/uiDesigner.xml 209 | .idea/**/dbnavigator.xml 210 | 211 | # Gradle 212 | .idea/**/gradle.xml 213 | .idea/**/libraries 214 | 215 | # Gradle and Maven with auto-import 216 | # When using Gradle or Maven with auto-import, you should exclude module files, 217 | # since they will be recreated, and may cause churn. Uncomment if using 218 | # auto-import. 219 | # .idea/artifacts 220 | # .idea/compiler.xml 221 | # .idea/jarRepositories.xml 222 | # .idea/modules.xml 223 | # .idea/*.iml 224 | # .idea/modules 225 | # *.iml 226 | # *.ipr 227 | 228 | # CMake 229 | cmake-build-*/ 230 | 231 | # Mongo Explorer plugin 232 | .idea/**/mongoSettings.xml 233 | 234 | # File-based project format 235 | *.iws 236 | 237 | # IntelliJ 238 | out/ 239 | 240 | # mpeltonen/sbt-idea plugin 241 | .idea_modules/ 242 | 243 | # JIRA plugin 244 | atlassian-ide-plugin.xml 245 | 246 | # Cursive Clojure plugin 247 | .idea/replstate.xml 248 | 249 | # SonarLint plugin 250 | .idea/sonarlint/ 251 | 252 | # Crashlytics plugin (for Android Studio and IntelliJ) 253 | com_crashlytics_export_strings.xml 254 | crashlytics.properties 255 | crashlytics-build.properties 256 | fabric.properties 257 | 258 | # Editor-based Rest Client 259 | .idea/httpRequests 260 | 261 | # Android studio 3.1+ serialized cache file 262 | .idea/caches/build_file_checksums.ser 263 | 264 | ### JetBrains+all Patch ### 265 | # Ignore everything but code style settings and run configurations 266 | # that are supposed to be shared within teams. 267 | 268 | .idea/* 269 | 270 | !.idea/codeStyles 271 | !.idea/runConfigurations 272 | 273 | ### Linux ### 274 | *~ 275 | 276 | # temporary files which can be created if a process still has a handle open of a deleted file 277 | .fuse_hidden* 278 | 279 | # KDE directory preferences 280 | .directory 281 | 282 | # Linux trash folder which might appear on any partition or disk 283 | .Trash-* 284 | 285 | # .nfs files are created when an open file is removed but is still being accessed 286 | .nfs* 287 | 288 | ### macOS ### 289 | # General 290 | .DS_Store 291 | .AppleDouble 292 | .LSOverride 293 | 294 | # Icon must end with two \r 295 | Icon 296 | 297 | 298 | # Thumbnails 299 | ._* 300 | 301 | # Files that might appear in the root of a volume 302 | .DocumentRevisions-V100 303 | .fseventsd 304 | .Spotlight-V100 305 | .TemporaryItems 306 | .Trashes 307 | .VolumeIcon.icns 308 | .com.apple.timemachine.donotpresent 309 | 310 | # Directories potentially created on remote AFP share 311 | .AppleDB 312 | .AppleDesktop 313 | Network Trash Folder 314 | Temporary Items 315 | .apdisk 316 | 317 | ### macOS Patch ### 318 | # iCloud generated files 319 | *.icloud 320 | 321 | ### Python ### 322 | # Byte-compiled / optimized / DLL files 323 | 324 | # C extensions 325 | 326 | # Distribution / packaging 327 | 328 | # PyInstaller 329 | # Usually these files are written by a python script from a template 330 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 331 | 332 | # Installer logs 333 | 334 | # Unit test / coverage reports 335 | 336 | # Translations 337 | 338 | # Django stuff: 339 | 340 | # Flask stuff: 341 | 342 | # Scrapy stuff: 343 | 344 | # Sphinx documentation 345 | 346 | # PyBuilder 347 | 348 | # Jupyter Notebook 349 | 350 | # IPython 351 | 352 | # pyenv 353 | # For a library or package, you might want to ignore these files since the code is 354 | # intended to run in multiple environments; otherwise, check them in: 355 | # .python-version 356 | 357 | # pipenv 358 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 359 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 360 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 361 | # install all needed dependencies. 362 | 363 | # poetry 364 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 365 | # This is especially recommended for binary packages to ensure reproducibility, and is more 366 | # commonly ignored for libraries. 367 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 368 | 369 | # pdm 370 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 371 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 372 | # in version control. 373 | # https://pdm.fming.dev/#use-with-ide 374 | 375 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 376 | 377 | # Celery stuff 378 | 379 | # SageMath parsed files 380 | 381 | # Environments 382 | 383 | # Spyder project settings 384 | 385 | # Rope project settings 386 | 387 | # mkdocs documentation 388 | 389 | # mypy 390 | 391 | # Pyre type checker 392 | 393 | # pytype static type analyzer 394 | 395 | # Cython debug symbols 396 | 397 | # PyCharm 398 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 399 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 400 | # and can be added to the global gitignore or merged into this file. For a more nuclear 401 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 402 | 403 | ### Python Patch ### 404 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 405 | poetry.toml 406 | 407 | # ruff 408 | .ruff_cache/ 409 | 410 | # LSP config files 411 | pyrightconfig.json 412 | 413 | ### venv ### 414 | # Virtualenv 415 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 416 | [Bb]in 417 | [Ii]nclude 418 | [Ll]ib 419 | [Ll]ib64 420 | [Ll]ocal 421 | [Ss]cripts 422 | pyvenv.cfg 423 | pip-selfcheck.json 424 | 425 | ### VisualStudioCode ### 426 | .vscode/* 427 | !.vscode/settings.json 428 | !.vscode/tasks.json 429 | !.vscode/launch.json 430 | !.vscode/extensions.json 431 | !.vscode/*.code-snippets 432 | 433 | # Local History for Visual Studio Code 434 | .history/ 435 | 436 | # Built Visual Studio Code Extensions 437 | *.vsix 438 | 439 | ### VisualStudioCode Patch ### 440 | # Ignore all local history of files 441 | .history 442 | .ionide 443 | 444 | ### Windows ### 445 | # Windows thumbnail cache files 446 | Thumbs.db 447 | Thumbs.db:encryptable 448 | ehthumbs.db 449 | ehthumbs_vista.db 450 | 451 | # Dump file 452 | *.stackdump 453 | 454 | # Folder config file 455 | [Dd]esktop.ini 456 | 457 | # Recycle Bin used on file shares 458 | $RECYCLE.BIN/ 459 | 460 | # Windows Installer files 461 | *.cab 462 | *.msi 463 | *.msix 464 | *.msm 465 | *.msp 466 | 467 | # Windows shortcuts 468 | *.lnk 469 | 470 | # End of https://www.toptal.com/developers/gitignore/api/git,flask,linux,macos,python,windows,jetbrains+all,visualstudiocode,venv --------------------------------------------------------------------------------