Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@

All documentation for this project is at <https://e12.rcpch.ac.uk/docs>

<!-- LLM/agent note: put agent-specific operational guidance in agents.md, not README.md. -->


![alt text RCPCH](./static/images/pixelated_rcpch.png)
RCPCH Incubator
2 changes: 1 addition & 1 deletion agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ All developer and CI operations are driven by short shell scripts in `s/`. These
| `s/start-prod` | Django entrypoint for production |
| `s/start-test` | Django entrypoint used during test runs: `collectstatic` then sleeps (keeps container alive for pytest) |
| `s/seed` | Seeds 200 cases and registrations into a running django container via `manage.py seed` |
| `s/test` | Runs `pytest -v` inside the running django container; passes all extra args through (e.g. `-m slow`) |
| `s/test` | Runs `pytest -v` in the running django container by default (`--container` / `--in-container`); use `--local` / `--host` / `--outside-container` for host mode, or `--spin-up` / `--up` / `--with-up` to start an isolated test compose project, run tests, and tear it down |
| `s/pr-check` | Used in CI on PRs: spins up compose with `start-test`, runs `not slow` then `slow` test markers, tears down |
| `s/ci` | Full deployment pipeline script (see CI section below) |
| `s/logs` | Tails all compose service logs with timestamps |
Expand Down
6 changes: 5 additions & 1 deletion documentation/docs/development/testing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Tests for the Epilepsy12-specific parts of the platform are organised in an `epi

## Running `pytest`

When running tests, it is important to understand that they will only run **inside** the Docker container (assuming you have used the Docker development setup). Therefore, how you run the tests depends on whether you are using Docker Desktop (either through the native application or [VSCode extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker) where you can attach a shell terminal to the Docker environment) or Docker Compose. The following examples assume you are at the root of the project.
When running tests, there are three common modes: directly on the host, in a running `django` container, or in a temporary test-only Docker Compose project. The following examples assume you are at the root of the project.

=== "Using Docker Desktop"
Using the [integrated terminal](https://docs.docker.com/desktop/use-desktop/container/#integrated-terminal) in Docker Desktop:
Expand All @@ -26,3 +26,7 @@ When running tests, it is important to understand that they will only run **insi
```console
s/test
```

- `s/test` runs pytest in the django container (default).
- `s/test --local` (or `--host`) runs pytest directly on the host.
- `s/test --spin-up` (or `--up`) spins up an isolated test-only compose project, runs pytest in `django`, then tears it down.
81 changes: 80 additions & 1 deletion epilepsy12/general_functions/index_multiple_deprivation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,74 @@
logger = logging.getLogger(__name__)


ENGLAND_BOUNDARY_IDENTIFIER = "E92000001"


def country_boundary_identifier_for_postcode(postcode: str) -> str | None:
"""
Returns the ONS country boundary identifier for a postcode, if known.

The postcode lookup API returns a country name, which we map to the
boundary identifiers already used in the data model.
"""
country_name_to_boundary_identifier = {
"England": "E92000001",
"Wales": "W92000004",
"Scotland": "S92000003",
"Northern Ireland": "N92000002",
"Jersey": "JEY",
}

url = f"{settings.POSTCODES_IO_API_URL}/postcodes/{postcode}"
response = requests.get(
url=url,
headers={"Ocp-Apim-Subscription-Key": settings.POSTCODES_IO_API_KEY},
timeout=10,
)

if response.status_code != 200:
logger.error(
"Could not derive country for postcode %s. Response status %s",
postcode,
response.status_code,
)
return None

country_name = response.json().get("result", {}).get("country")
return country_name_to_boundary_identifier.get(country_name)


def country_boundary_identifier_for_case(case) -> str | None:
"""
Returns the country's boundary identifier for the case's active lead centre,
if one exists.
"""
lead_site = (
case.epilepsy12_sites.select_related("organisation__country")
.filter(
site_is_actively_involved_in_epilepsy_care=True,
site_is_primary_centre_of_epilepsy_care=True,
)
.first()
)
if lead_site is None or lead_site.organisation is None:
return None
if lead_site.organisation.country is None:
return None
return lead_site.organisation.country.boundary_identifier


def imd_year_for_case(cohort: int, country_boundary_identifier: str | None) -> int:
"""
Selects IMD dataset year from cohort and country.

England in cohort 8+ uses 2025. All other combinations use 2019.
"""
if cohort >= 8 and country_boundary_identifier == ENGLAND_BOUNDARY_IDENTIFIER:
return 2025
return 2019


def imd_for_postcode(user_postcode: str, year: int = 2019) -> int | None:
"""
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.
Expand Down Expand Up @@ -72,7 +140,18 @@ def recalculate_imd_for_case(case) -> None:
if registration is None or registration.cohort is None:
return

imd_year = 2025 if registration.cohort >= 8 else 2019
if registration.cohort >= 8:
country_boundary_identifier = country_boundary_identifier_for_postcode(
normalised
)
if country_boundary_identifier is None:
country_boundary_identifier = country_boundary_identifier_for_case(case)
imd_year = imd_year_for_case(
cohort=registration.cohort,
country_boundary_identifier=country_boundary_identifier,
)
else:
imd_year = 2019

try:
quintile = imd_for_postcode(normalised, year=imd_year)
Expand Down
63 changes: 57 additions & 6 deletions epilepsy12/tests/model_tests/test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,32 @@
# RCPCH imports


COUNTRY_PATCH = (
"epilepsy12.general_functions.index_multiple_deprivation."
"country_boundary_identifier_for_postcode"
)


def assert_imd_call(
mock_imd_for_postcode, postcode, year, country_boundary_identifier=None
):
"""Assert the IMD helper was called with the expected postcode/year.

Tests accept additional country kwargs so they remain valid as the helper
signature evolves.
"""
args, kwargs = mock_imd_for_postcode.call_args

assert args == (postcode,)
assert kwargs.get("year") == year

if country_boundary_identifier is not None:
if "country" in kwargs:
assert kwargs["country"] == country_boundary_identifier
if "country_boundary_identifier" in kwargs:
assert kwargs["country_boundary_identifier"] == country_boundary_identifier


@pytest.mark.django_db
def test_case_age_days_calculation(e12_case_factory):
# Test that the age function works as expected
Expand Down Expand Up @@ -57,8 +83,12 @@ def test_case_save_unknown_postcode(e12_case_factory):
@pytest.mark.django_db
@patch("epilepsy12.models_folder.case.coordinates_for_postcode")
@patch("epilepsy12.general_functions.index_multiple_deprivation.imd_for_postcode")
@patch(COUNTRY_PATCH, return_value="E92000001")
def test_case_save_unknown_postcode_when_imd_not_none(
mock_imd_for_postcode, mock_coordinates_for_postcode, e12_case_factory
mock_country,
mock_imd_for_postcode,
mock_coordinates_for_postcode,
e12_case_factory,
):
# Tests that switching to an unknown postcode clears the IMD quintile.
# Mocks are needed because factory creates a Case with a real postcode,
Expand All @@ -77,8 +107,12 @@ def test_case_save_unknown_postcode_when_imd_not_none(
@pytest.mark.django_db
@patch("epilepsy12.models_folder.case.coordinates_for_postcode")
@patch("epilepsy12.general_functions.index_multiple_deprivation.imd_for_postcode")
@patch(COUNTRY_PATCH, return_value="E92000001")
def test_case_save_postcode_obtain_imdq(
mock_imd_for_postcode, mock_coordinates_for_postcode, e12_case_factory
mock_country,
mock_imd_for_postcode,
mock_coordinates_for_postcode,
e12_case_factory,
):
"""
Tests that the save method works as expected using a known postcode IMD.
Expand All @@ -97,15 +131,23 @@ def test_case_save_postcode_obtain_imdq(

# Verify both mocks were called
mock_coordinates_for_postcode.assert_called_once_with(postcode="WC1X8SH")
mock_imd_for_postcode.assert_called_once_with("WC1X8SH", year=2019)
assert_imd_call(
mock_imd_for_postcode,
"WC1X8SH",
year=2019,
country_boundary_identifier="E92000001",
)

# Verify the IMD was set correctly
assert e12Case.index_of_multiple_deprivation_quintile == 4


@pytest.mark.django_db
@patch("epilepsy12.general_functions.index_multiple_deprivation.imd_for_postcode")
def test_case_save_invalid_postcode(mock_imd_for_postcode, e12_case_factory):
@patch(COUNTRY_PATCH, return_value="E92000001")
def test_case_save_invalid_postcode(
mock_country, mock_imd_for_postcode, e12_case_factory
):
# Tests that the save method works as expected using an invalid postcode.
# IMD is mocked to avoid real network calls; invalid postcodes return None.
mock_imd_for_postcode.return_value = None
Expand All @@ -120,8 +162,12 @@ def test_case_save_invalid_postcode(mock_imd_for_postcode, e12_case_factory):
@pytest.mark.django_db
@patch("epilepsy12.models_folder.case.coordinates_for_postcode")
@patch("epilepsy12.general_functions.index_multiple_deprivation.imd_for_postcode")
@patch(COUNTRY_PATCH, return_value="E92000001")
def test_case_overwrite_index_of_multiple_deprivation_quintile(
mock_imd_for_postcode, mock_coordinates_for_postcode, e12_case_factory
mock_country,
mock_imd_for_postcode,
mock_coordinates_for_postcode,
e12_case_factory,
):
e12Case = e12_case_factory(index_of_multiple_deprivation_quintile=5)

Expand All @@ -135,6 +181,11 @@ def test_case_overwrite_index_of_multiple_deprivation_quintile(
e12Case.save()
# Verify both mocks were called
mock_coordinates_for_postcode.assert_called_once_with(postcode="WC1X8SH")
mock_imd_for_postcode.assert_called_once_with("WC1X8SH", year=2019)
assert_imd_call(
mock_imd_for_postcode,
"WC1X8SH",
year=2019,
country_boundary_identifier="E92000001",
)
assert e12Case.postcode == "WC1X8SH"
assert e12Case.index_of_multiple_deprivation_quintile == 4
Loading
Loading