Skip to content

Commit b970826

Browse files
authored
Merge branch 'live' into mbarton/view-old-cohorts-2
2 parents ad03a1b + 6236c62 commit b970826

16 files changed

Lines changed: 1626 additions & 259 deletions

File tree

agents.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# agents.md — RCPCH Audit Engine: Agent Orientation
2+
3+
> This file is intended as a growing reference for AI agents and developers working on this codebase. It will be expanded over time to cover each major area of the project.
4+
5+
---
6+
7+
## Project Overview
8+
9+
**rcpch-audit-engine** is the backend and web application for **Epilepsy12**, a national clinical audit run by the Royal College of Paediatrics and Child Health (RCPCH). It collects, validates, and reports on epilepsy care for children across England and Wales.
10+
11+
- **Framework**: Django (Python)
12+
- **Database**: PostgreSQL with the PostGIS extension (spatial queries)
13+
- **Deployment target**: Azure Container Apps
14+
- **Container registry**: Azure Container Registry (ACR)
15+
- **Reverse proxy**: Caddy (handles HTTPS)
16+
- **Task queue**: Celery (celerybeat for scheduled tasks)
17+
- **Primary app**: `epilepsy12/` — all audit domain logic lives here
18+
- **Documentation**: MkDocs site, served via a separate Docker Compose service and built into the image at deploy time
19+
- **Full docs**: https://e12.rcpch.ac.uk/docs
20+
21+
The main Django project config is in `rcpch-audit-engine/` (the inner directory), including `settings.py`, `urls.py`, `logging_settings.py`, and `build_info.py`.
22+
23+
---
24+
25+
## The `s/` Scripts Directory
26+
27+
All developer and CI operations are driven by short shell scripts in `s/`. These exist to reduce typing, reduce errors, and ensure consistency. Scripts are plain bash; make them executable with `chmod +x s/<script>` if needed.
28+
29+
| Script | Purpose |
30+
|---|---|
31+
| `s/up` | `docker compose up` — starts all services (caddy, django, postgis, mkdocs) |
32+
| `s/down` | `docker compose down` — stops services, does **not** destroy volumes or images |
33+
| `s/rebuild` | Destroys containers and images then calls `s/up` (runs `s/remove-containers-and-images` then `s/up`) |
34+
| `s/remove-containers-and-images` | Removes local containers and images without touching volumes |
35+
| `s/DELETE-LOCAL-DATA` | **Destructive** — prompts for confirmation, then runs `docker compose down -v --rmi local` removing volumes too. Never run on live/production. |
36+
| `s/start-dev` | Django entrypoint for development: `collectstatic`, `migrate`, seed groups/permissions, create dev users, then `runserver` |
37+
| `s/start-prod` | Django entrypoint for production |
38+
| `s/start-test` | Django entrypoint used during test runs: `collectstatic` then sleeps (keeps container alive for pytest) |
39+
| `s/seed` | Seeds 200 cases and registrations into a running django container via `manage.py seed` |
40+
| `s/test` | Runs `pytest -v` inside the running django container; passes all extra args through (e.g. `-m slow`) |
41+
| `s/pr-check` | Used in CI on PRs: spins up compose with `start-test`, runs `not slow` then `slow` test markers, tears down |
42+
| `s/ci` | Full deployment pipeline script (see CI section below) |
43+
| `s/logs` | Tails all compose service logs with timestamps |
44+
| `s/psql` | Opens a psql shell inside the postgis container |
45+
| `s/django-shell` | Opens a Django shell inside the django container |
46+
| `s/create-superuser` | Creates a Django superuser inside the running container |
47+
| `s/get-build-info` | Writes git metadata (hash, branch, etc.) to `build_info.json` for the build info page |
48+
| `s/push-envs-github-secret` | Pushes environment secrets to GitHub Actions secrets |
49+
| `s/trust-caddy-ca` | Trusts Caddy's local CA certificate for local HTTPS development |
50+
| `s/restart` | Restarts compose services |
51+
52+
The `DJANGO_START_COMMAND` environment variable controls which start script the django container runs. It defaults to `s/start-dev`; CI overrides it to `s/start-test` when running tests.
53+
54+
---
55+
56+
## CI / Deployment Pipeline
57+
58+
### GitHub Actions Workflows
59+
60+
| Workflow file | Trigger | Purpose |
61+
|---|---|---|
62+
| `run-docker-compose-test-on-pr.yml` | PR to any branch | Runs the full pytest suite via `s/pr-check` |
63+
| `deploy.yml` | Push to `live` branch | Full build, test, and deploy to Azure via `s/ci` |
64+
| `staging_e12-staging-web-app-service.yml` | (see file) | Staging App Service deployment |
65+
| `auto-add-issues-to-project.yml` | Issue events | Automatically adds issues to the GitHub Project board |
66+
67+
### The `s/ci` Deployment Script (called by `deploy.yml`)
68+
69+
This is the authoritative deploy sequence executed on every push to `live`:
70+
71+
1. **Login to Azure ACR**`az acr login`
72+
2. **Download `.env` from Azure File Share** — production secrets are stored in Azure Storage, not in the repo
73+
3. **Burn in build info**`s/get-build-info` writes git metadata to `build_info.json`
74+
4. **Build the Docker image**`docker compose build`
75+
5. **Build the MkDocs documentation** — runs inside the image; docs are embedded into the static files
76+
6. **Rebuild the image** — a second build to embed the freshly built docs
77+
7. **Tag and push to ACR** — tagged with the Git SHA: `<registry>.azurecr.io/e12-django:<SHA>`
78+
8. **Run tests**`s/test -m 'not slow'` then `s/test -m 'slow'` against a local Postgres container
79+
9. **Deploy to staging**`az containerapp revision copy` creates a new revision on the staging Container App
80+
10. **Deploy to production** — same command targets the live Container App
81+
82+
> Note: the image is pushed to ACR **before** tests run, so that an emergency deploy is possible from a known-good SHA even if tests are mid-flight.
83+
84+
### Authentication to Azure
85+
86+
The GitHub Actions workflow uses OIDC (`id-token: write` permission) with Azure federated credentials — no long-lived secrets for the Azure login itself. Remaining secrets (registry name, resource group, app names, storage account, etc.) are stored as GitHub Actions secrets and injected as environment variables into `s/ci`.
87+
88+
---
89+
90+
## Docker Compose Services
91+
92+
| Service | Image / Build | Role |
93+
|---|---|---|
94+
| `caddy` | `caddy` (official) | Reverse proxy, TLS termination, serves static docs |
95+
| `django` | `e12-django:built` (local build) | Main Django application |
96+
| `postgis` | `postgis/postgis:15-3.3` | PostgreSQL + PostGIS |
97+
| `mkdocs` | `e12-django:built` | Builds and optionally serves the MkDocs documentation |
98+
99+
All services share environment from `envs/.env` (not committed to git). Two named volumes are used: `caddy-data` and `postgis-data`.
100+
101+
---
102+
103+
## IMD Calculation — Design Notes
104+
105+
### Background
106+
107+
The **Index of Multiple Deprivation (IMD)** quintile is stored on `Case.index_of_multiple_deprivation_quintile`. The correct IMD year to use depends on the patient's **cohort**:
108+
109+
- Cohort < 8 → 2019 IMD (England 2019 / Wales 2019, based on 2011 LSOA boundaries)
110+
- Cohort ≥ 8 → 2025 IMD (England 2025, based on 2021 LSOA boundaries; Wales still 2019)
111+
112+
The cohort is stored on `Registration` and is derived from `Registration.first_paediatric_assessment_date`. The RCPCH Census Platform API was updated to **v2**, which now accepts a `year` parameter (`2019` or `2025`) in the IMD endpoint.
113+
114+
### The problem with putting IMD in `Case.save()`
115+
116+
`Case` and `Registration` have a 1-to-1 relation, but they can be created in either order. If IMD is calculated inside `Case.save()`, the Registration (and therefore the cohort) may not exist yet on first save, making it impossible to know the correct year. Workarounds inside `save()` grow complexity and can cause `ValueError` when filtering on an unsaved instance.
117+
118+
### Current design: signal-driven utility
119+
120+
IMD is calculated in a **single utility function** and triggered by **`post_save` signals** on both models:
121+
122+
```
123+
epilepsy12/general_functions/index_multiple_deprivation.py
124+
└── recalculate_imd_for_case(case)
125+
- no-op if postcode missing or unknown
126+
- no-op if Registration or cohort not yet available
127+
- derives imd_year from registration.cohort
128+
- calls imd_for_postcode(postcode, year=imd_year) once
129+
- persists via queryset .update() to avoid re-triggering Case.save()
130+
```
131+
132+
```
133+
epilepsy12/signals.py
134+
├── pre_save / post_save on Case
135+
│ → fires recalculate_imd_for_case when postcode changes
136+
└── pre_save / post_save on Registration
137+
→ fires recalculate_imd_for_case when first_paediatric_assessment_date changes
138+
```
139+
140+
`Case.save()` itself only normalises the postcode (strip spaces/dashes, uppercase) and updates geolocation coordinates (`location_wgs84`, `location_bng`). It sets `index_of_multiple_deprivation_quintile = None` when postcode switches to an unknown/placeholder value.
141+
142+
### Bulk recalculation
143+
144+
To recalculate IMD for existing records (e.g. after a cohort boundary change or API update):
145+
146+
```bash
147+
s/recalculate-imd # all cohorts
148+
s/recalculate-imd 6 # cohort 6 only
149+
```
150+
151+
This wraps `manage.py recalculate_imd --all` / `--cohort N` (`epilepsy12/management/commands/recalculate_imd.py`).
152+
153+
### Key constants
154+
155+
| Setting | Location |
156+
|---|---|
157+
| `RCPCH_CENSUS_PLATFORM_URL` | `settings.py` / `.env` |
158+
| `RCPCH_CENSUS_PLATFORM_TOKEN` | `settings.py` / `.env` |
159+
| `UNKNOWN_POSTCODES_NO_SPACES` | `epilepsy12/constants/postcodes.py` |
160+
161+
---
162+
163+
## Areas to Expand
164+
165+
The following sections will be added over time:
166+
167+
- `epilepsy12/` app structure (models, views, forms, KPIs, migrations)
168+
- `epilepsy12/models_folder/` — domain model breakdown
169+
- `epilepsy12/views/` — view organisation and HTMX patterns
170+
- `epilepsy12/constants/` — audit constants and clinical coding
171+
- `epilepsy12/management/commands/` — custom management commands including `seed`
172+
- `epilepsy12/tests/` — test structure and pytest markers
173+
- `epilepsy12/common_view_functions/` — shared view logic
174+
- KPI calculation logic (`kpi.py`, `organisational_audit.py`)
175+
- Permissions and decorator patterns
176+
- Celery / celerybeat scheduled tasks
177+
- REST API (serializers, DRF)
178+
- Template and HTMX patterns

documentation/docs/development/imd.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ Index of multiple deprivation is dependent on geography, and the underlying assu
1313

1414
The country is first broken into areas of similar population size, known as low layer super output areas (LSOAs) and the findings for each measure summarized at this level. The LSOAs are then ranked in order by raw score, with the lower raw scores representing the least deprived. These are then broken into quantiles, depending on the size of the population / reporting priorities. It is typical to report as deciles or quintiles.
1515

16-
The last English data was published in 2019, with Wales the same year.
16+
The last English data was published in 2019 and updated in 2025. Wales was published in 2019.
1717

1818
Jersey has recently been added to Epilepsy12 and is currently not supported by the RCPCH Census Platform. Because the population is small (~100,000), IMD is reported in vingtaines, rather than quintiles or deciles. The actual data is not published and is being requested for inclusion. In Epilepsy12 currently IMD quantiles are therefore not reported.

documentation/docs/development/postcodes.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,17 @@ Postcodes are used primarily to calculate indices of multiple deprivation, but a
1111

1212
Postcodes are passed to `api.rcpch.ac.uk/postcodes`. This is an RCPCH managed instance of the `postcodes.io` and requires an api key. It reports information against postcode which include LSOA (see Indices of Multiple Deprivation) as well as longitude and latitude. These latter data points are used for scatter plots.
1313

14-
Jersey is currently not supported as there is no open source solution for mapping currently though this is tracked in a [github issue](https://github.com/rcpch/rcpch-audit-engine/issues/1107)
14+
Jersey is currently not supported as there is no open source solution for mapping currently though this is tracked in a [github issue](https://github.com/rcpch/rcpch-audit-engine/issues/1107)
15+
16+
## Postcode and IMD
17+
18+
To calculate an IMD we need the year for England as new data was published in 2025.
19+
20+
If the patient is in cohort 8, this will by default use 2025 data. Earlier cohorts will use 2019.
21+
22+
To recalculate all postcodes in a given cohort use the convenience script:
23+
24+
```console
25+
s/recalculate-imd # all cohorts
26+
s/recalculate-imd 6 # cohort 6 only
27+
```

epilepsy12/general_functions/index_multiple_deprivation.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,85 @@
99
# Third party imports
1010
from django.conf import settings
1111

12-
# RCPCH imports
13-
1412
# Logging setup
1513
logger = logging.getLogger(__name__)
1614

1715

18-
def imd_for_postcode(user_postcode: str) -> int:
16+
def imd_for_postcode(user_postcode: str, year: int = 2019) -> int | None:
1917
"""
20-
Makes an API call to the RCPCH Census Platform with postcode and quantile_type
18+
Makes an API call to the RCPCH Census Platform with postcode, quantile_type and IMD year to get the quantile for the given postcode and quantile type.
19+
The English IMD have 2019 and 2025 versions, the Welsh only 2019.
2120
Postcode - can have spaces or not - this is processed by the API
2221
Quantile - this is an integer representing what quantiles are requested (eg quintile, decile etc)
22+
Returns the quantile for the given postcode and quantile type, or None if there was an error
2323
"""
24-
24+
if year not in [2019, 2025]:
25+
logger.error("Invalid year %s for IMD. Must be 2019 or 2025", year)
26+
return None
2527
response = requests.get(
26-
url=f"{settings.RCPCH_CENSUS_PLATFORM_URL}/index_of_multiple_deprivation_quantile?postcode={user_postcode}&quantile=5",
28+
url=f"{settings.RCPCH_CENSUS_PLATFORM_URL}/index_of_multiple_deprivation_quantile?postcode={user_postcode}&quantile=5&year={year}",
2729
headers={"Subscription-Key": f"{settings.RCPCH_CENSUS_PLATFORM_TOKEN}"},
2830
timeout=10, # times out after 10 seconds
2931
)
3032

3133
if response.status_code != 200:
3234
logger.error(
33-
"Could not get deprivation score for %s. Response status %s", user_postcode, response.status_code
35+
"Could not get deprivation score for %s. Response status %s",
36+
user_postcode,
37+
response.status_code,
3438
)
3539
return None
3640

3741
return response.json()["result"]["data_quantile"]
42+
43+
44+
def recalculate_imd_for_case(case) -> None:
45+
"""
46+
Recalculates the IMD quintile for a Case and persists only that field.
47+
48+
No-op if any of the following are true:
49+
- case.postcode is absent or an unknown/placeholder postcode
50+
- case has no Registration yet, or Registration.cohort is not yet set
51+
52+
Uses queryset .update() rather than case.save() to avoid re-triggering
53+
Case.post_save signals and coordinate lookups.
54+
Also updates the in-memory instance so the caller sees the new value.
55+
"""
56+
from ..constants import UNKNOWN_POSTCODES_NO_SPACES
57+
58+
postcode = case.postcode
59+
if not postcode:
60+
return
61+
62+
normalised = postcode.replace(" ", "").replace("-", "").upper()
63+
if normalised in UNKNOWN_POSTCODES_NO_SPACES:
64+
return
65+
66+
try:
67+
registration = case.registration
68+
except Exception:
69+
# No registration exists yet — IMD will be recalculated once Registration is saved.
70+
return
71+
72+
if registration is None or registration.cohort is None:
73+
return
74+
75+
imd_year = 2025 if registration.cohort >= 8 else 2019
76+
77+
try:
78+
quintile = imd_for_postcode(normalised, year=imd_year)
79+
except Exception as error:
80+
logger.exception(
81+
"Cannot calculate deprivation score for %s: %s", normalised, error
82+
)
83+
quintile = None
84+
85+
# Use queryset update to skip Case.save() and its signals entirely.
86+
from django.apps import apps
87+
88+
Case = apps.get_model("epilepsy12", "Case")
89+
Case.objects.filter(pk=case.pk).update(
90+
index_of_multiple_deprivation_quintile=quintile
91+
)
92+
# Keep the in-memory object consistent.
93+
case.index_of_multiple_deprivation_quintile = quintile
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Management command to recalculate the Index of Multiple Deprivation (IMD) quintile
3+
for all Cases in a given cohort (or all cohorts).
4+
5+
Usage:
6+
# All cases across all cohorts:
7+
python manage.py recalculate_imd --all
8+
9+
# All cases in cohort 6:
10+
python manage.py recalculate_imd --cohort 6
11+
12+
Or via the convenience script:
13+
s/recalculate-imd # all cohorts
14+
s/recalculate-imd 6 # cohort 6 only
15+
"""
16+
17+
# Standard imports
18+
import logging
19+
20+
# Django imports
21+
from django.core.management.base import BaseCommand, CommandError
22+
23+
# RCPCH imports
24+
from epilepsy12.models import Case
25+
from epilepsy12.general_functions.index_multiple_deprivation import (
26+
recalculate_imd_for_case,
27+
)
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
class Command(BaseCommand):
33+
help = "Recalculate IMD quintile for all Cases in a given cohort (or all cohorts)."
34+
35+
def add_arguments(self, parser):
36+
group = parser.add_mutually_exclusive_group(required=True)
37+
group.add_argument(
38+
"--cohort",
39+
type=int,
40+
metavar="N",
41+
help="Recalculate only for cases in this cohort number.",
42+
)
43+
group.add_argument(
44+
"--all",
45+
action="store_true",
46+
dest="all_cohorts",
47+
help="Recalculate for all cases regardless of cohort.",
48+
)
49+
50+
def handle(self, *args, **options):
51+
if options["all_cohorts"]:
52+
cases = Case.objects.select_related("registration").all()
53+
self.stdout.write("Recalculating IMD for all cases…")
54+
else:
55+
cohort = options["cohort"]
56+
cases = Case.objects.select_related("registration").filter(
57+
registration__cohort=cohort
58+
)
59+
self.stdout.write(f"Recalculating IMD for cohort {cohort}…")
60+
61+
total = cases.count()
62+
if total == 0:
63+
self.stdout.write(self.style.WARNING("No matching cases found."))
64+
return
65+
66+
success = 0
67+
skipped = 0
68+
errors = 0
69+
70+
for ix, case in enumerate(cases, 1):
71+
try:
72+
before = case.index_of_multiple_deprivation_quintile
73+
recalculate_imd_for_case(case)
74+
after = case.index_of_multiple_deprivation_quintile
75+
76+
if after is None and not case.postcode:
77+
skipped += 1
78+
self.stdout.write(
79+
f" [{ix}/{total}] Case {case.pk}: skipped (no postcode)"
80+
)
81+
else:
82+
success += 1
83+
self.stdout.write(
84+
f" [{ix}/{total}] Case {case.pk}: {before}{after}"
85+
)
86+
except Exception as exc:
87+
errors += 1
88+
self.stderr.write(
89+
self.style.ERROR(f" [{ix}/{total}] Case {case.pk}: ERROR — {exc}")
90+
)
91+
logger.exception("recalculate_imd failed for case %s", case.pk)
92+
93+
self.stdout.write(
94+
self.style.SUCCESS(
95+
f"\nDone. {success} updated, {skipped} skipped, {errors} errors (total {total})."
96+
)
97+
)

0 commit comments

Comments
 (0)