Skip to content

Commit 62f5254

Browse files
authored
Merge pull request #166 from LandRegistry/database
Database functionality
2 parents 2655947 + 44cb3aa commit 62f5254

17 files changed

+345
-57
lines changed

Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
# Stage 1: Build Python wheels
22
FROM python:3.14-slim AS builder
33

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+
411
WORKDIR /app
512

613
COPY requirements.txt ./
714
# Build wheels instead of direct install
815
RUN pip wheel --no-cache-dir --no-deps -r requirements.txt -w /wheels
916

17+
1018
# Stage 2: Final runtime image
1119
FROM python:3.14-slim
1220

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+
1326
# Create non-root user
1427
RUN addgroup --system appgroup && adduser --system --group appuser
1528

@@ -26,6 +39,7 @@ RUN pip install --no-cache-dir /wheels/*
2639
# Copy application code
2740
COPY --chown=appuser:appgroup govuk-frontend-flask.py config.py ./
2841
COPY --chown=appuser:appgroup app app
42+
COPY --chown=appuser:appgroup migrations migrations
2943

3044
# Copy entrypoint script into PATH
3145
COPY --chown=appuser:appgroup docker-entrypoint.sh /usr/local/bin/

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ CONTACT_EMAIL=[contact email]
3030
CONTACT_PHONE=[contact phone]
3131
DEPARTMENT_NAME=[name of department]
3232
DEPARTMENT_URL=[url of department]
33+
POSTGRES_DB=db
34+
POSTGRES_HOST=db
35+
POSTGRES_PASSWORD=db_password
36+
POSTGRES_PORT=5432
37+
POSTGRES_USER=db_user
3338
REDIS_HOST=cache
3439
REDIS_PORT=6379
3540
SECRET_KEY=[see below]
@@ -69,11 +74,12 @@ flowchart TB
6974
compose(compose.yml)
7075
nginx(nginx:stable-alpine)
7176
node(node:jod-alpine)
77+
postgres(postgres:18-alpine)
7278
python(python:3.14-slim)
7379
redis(redis:7-alpine)
7480
75-
compose -- Creates --> App & Cache & Web
76-
App -- Depends on --> Cache
81+
compose -- Creates --> App & Cache & Web & Database
82+
App -- Depends on --> Cache & Database
7783
Web -- Depends on --> App
7884
7985
subgraph Web
@@ -85,6 +91,10 @@ flowchart TB
8591
python
8692
end
8793
94+
subgraph Database
95+
postgres
96+
end
97+
8898
subgraph Cache
8999
redis
90100
end
@@ -99,8 +109,9 @@ flowchart TB
99109
nginx(NGINX)
100110
flask(Gunicorn/Flask)
101111
static@{ shape: lin-cyl, label: "Static files" }
112+
db@{ shape: cyl, label: "PostgreSQL" }
102113
103-
client -- https:443 --> nginx -- http:5000 --> flask
114+
client -- https:443 --> nginx -- http:5000 --> flask -- postgres:5432 --> db
104115
flask -- redis:6379 --> redis
105116
106117
subgraph Web
@@ -111,6 +122,10 @@ flowchart TB
111122
flask
112123
end
113124
125+
subgraph Database
126+
db
127+
end
128+
114129
subgraph Cache
115130
redis
116131
end

app/__init__.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from flask import Flask
44
from flask_limiter import Limiter
55
from flask_limiter.util import get_remote_address
6+
from flask_migrate import Migrate
7+
from flask_sqlalchemy import SQLAlchemy
68
from flask_wtf.csrf import CSRFProtect # type: ignore[import]
79
from govuk_frontend_wtf.main import WTFormsHelpers # type: ignore[import]
810
from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader
@@ -11,8 +13,10 @@
1113
from config import Config
1214

1315
# Initialize Flask extensions. These are initialized here for easier access.
14-
csrf = CSRFProtect()
15-
limiter = Limiter(get_remote_address, default_limits=["2 per second", "60 per minute"])
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()
1620

1721

1822
def create_app(config_class: Type[Config] = Config) -> Flask:
@@ -24,7 +28,7 @@ def create_app(config_class: Type[Config] = Config) -> Flask:
2428
Returns:
2529
A configured Flask application instance.
2630
"""
27-
app: Flask = Flask(__name__)
31+
app: Flask = Flask(__name__) # type: ignore[assignment]
2832
app.config.from_object(config_class)
2933
app.jinja_env.globals["govukRebrand"] = True
3034
app.jinja_env.lstrip_blocks = True
@@ -48,7 +52,9 @@ def create_app(config_class: Type[Config] = Config) -> Flask:
4852

4953
# Initialize Flask extensions
5054
csrf.init_app(app)
55+
db.init_app(app)
5156
limiter.init_app(app)
57+
migrate.init_app(app, db)
5258
WTFormsHelpers(app)
5359

5460
# Register blueprints. These define different sections of the application.
@@ -57,3 +63,6 @@ def create_app(config_class: Type[Config] = Config) -> Flask:
5763
app.register_blueprint(main_bp)
5864

5965
return app
66+
67+
68+
from app import models # noqa: E402,F401

app/models.py

Whitespace-only changes.

compose.yml

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ services:
66
environment:
77
CONTACT_EMAIL: ${CONTACT_EMAIL}
88
CONTACT_PHONE: ${CONTACT_PHONE}
9+
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
910
DEPARTMENT_NAME: ${DEPARTMENT_NAME}
1011
DEPARTMENT_URL: ${DEPARTMENT_URL}
1112
REDIS_URL: redis://${REDIS_HOST}:${REDIS_PORT}
@@ -16,12 +17,20 @@ services:
1617
expose:
1718
- 5000
1819
healthcheck:
19-
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
20+
test:
21+
[
22+
"CMD",
23+
"python",
24+
"-c",
25+
"import urllib.request; urllib.request.urlopen('http://localhost:5000/health')",
26+
]
2027
interval: 30s
2128
timeout: 5s
2229
retries: 3
2330
start_period: 10s
2431
depends_on:
32+
db:
33+
condition: service_healthy
2534
cache:
2635
condition: service_healthy
2736
develop:
@@ -33,6 +42,29 @@ services:
3342
path: ./app
3443
target: /home/appuser/app
3544

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+
3668
cache:
3769
image: redis:7-alpine
3870
container_name: cache
@@ -75,3 +107,4 @@ services:
75107

76108
volumes:
77109
redis_data:
110+
pg_data:

config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ class Config(object):
1515
SESSION_COOKIE_HTTPONLY = True
1616
SESSION_COOKIE_SAMESITE = "Lax"
1717
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+
)
1825

1926

2027
class TestConfig(Config):
@@ -24,11 +31,13 @@ class TestConfig(Config):
2431
DEPARTMENT_NAME = "Department of Magical Law Enforcement"
2532
DEPARTMENT_URL = "https://www.example.com/"
2633
RATELIMIT_HEADERS_ENABLED = True
34+
RATELIMIT_STORAGE_URI = "memory://"
2735
SECRET_KEY = "4f378500459bb58fecf903ea3c113069f11f150b33388f56fc89f7edce0e6a84" # nosec B105
2836
SERVICE_NAME = "Apply for a wand licence"
2937
SERVICE_PHASE = "Beta"
3038
SERVICE_URL = "https://wand-licence.service.gov.uk"
3139
SESSION_COOKIE_HTTPONLY = True
3240
SESSION_COOKIE_SAMESITE = "Lax"
3341
SESSION_COOKIE_SECURE = True
42+
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
3443
TESTING = True

docker-entrypoint.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/bin/sh
22
set -e
33

4+
echo "Running DB migrations..."
5+
flask db upgrade
6+
47
# Dynamic worker count (default: 2×CPU + 1)
58
: "${WORKERS:=$(( $(nproc) * 2 + 1 ))}"
69
: "${BIND_ADDR:=0.0.0.0:5000}"

migrations/README

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Single-database configuration for Flask.

migrations/alembic.ini

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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

0 commit comments

Comments
 (0)