From 62a6bfaf0cb552d4b5e8ac4bd3d973c88d9eb941 Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 2 Oct 2026 22:59:45 +0100 Subject: [PATCH] feat(api): name each school's type group, and send search its nursery provision The school page and the search rows now print a school's type in the search filter's terms ("State school", "Independent school") instead of GIAS's 34 establishment types. The list, place and detail payloads carry type_group, computed with the filter's own type_group_for, so the two never disagree; a type in no group stays null and the page prints the register's name. Search rows flag a nursery class, so nursery_provision joins SCHOOL_COLUMNS. Co-Authored-By: Claude Opus 5.5 --- backend/app.py | 27 ++++++-- backend/schemas.py | 1 + backend/tests/test_type_group_payload.py | 81 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_type_group_payload.py diff --git a/backend/app.py b/backend/app.py index 15bf139..a746d74 100644 --- a/backend/app.py +++ b/backend/app.py @@ -634,6 +634,18 @@ def _with_whole_school_pupils(rows: pd.DataFrame, source: pd.DataFrame) -> pd.Da return rows.assign(total_pupils=whole) +def _with_type_group(rows: pd.DataFrame) -> pd.DataFrame: + """Name each row's search-filter type group, or None for a type in no group. + + The school page and the search rows print it ("State school") in place of + the GIAS establishment type, and an independent school gets a Fee-paying + flag from it. + """ + if "school_type" not in rows.columns: + return rows + return rows.assign(type_group=rows["school_type"].map(type_group_for)) + + # Input validation helpers def _names_in_group(names: pd.Series, in_group) -> set: """The distinct names in a column that a group predicate accepts. @@ -965,7 +977,7 @@ async def get_schools( total = len(schools_df) start_idx = (page - 1) * page_size end_idx = start_idx + page_size - schools_df = schools_df.iloc[start_idx:end_idx] + schools_df = _with_type_group(schools_df.iloc[start_idx:end_idx]) return { "schools": clean_for_json(schools_df), @@ -1029,6 +1041,7 @@ async def get_school_details(request: Request, urn: int): "school_name": latest.get("school_name", ""), "local_authority": latest.get("local_authority", ""), "school_type": latest.get("school_type", ""), + "type_group": type_group_for(latest.get("school_type")), "address": latest.get("address", ""), "religious_denomination": latest.get("religious_denomination", ""), "age_range": latest.get("age_range", ""), @@ -1522,13 +1535,12 @@ async def get_place(request: Request, kind: str, slug: str, # warning. Ordered de-duplication keeps the column order and the warning # cannot come back. # - # nursery_provision and parliamentary_constituency are not in - # SCHOOL_COLUMNS and the place table shows both. The `in rows.columns` - # guard is what keeps a mart the pipeline has not rebuilt working: those - # two are the optional GIAS columns data_loader degrades to NULL. + # parliamentary_constituency is not in SCHOOL_COLUMNS and the place table + # shows it. The `in rows.columns` guard is what keeps a mart the pipeline + # has not rebuilt working: it and nursery_provision are the optional GIAS + # columns data_loader degrades to NULL. cols = [c for c in dict.fromkeys( SCHOOL_COLUMNS + ["latitude", "longitude", "phase", - "nursery_provision", "parliamentary_constituency", "rwm_expected_pct", "attainment_8_score", "total_pupils"]) @@ -1558,7 +1570,8 @@ async def get_place(request: Request, kind: str, slug: str, # variants that exist rather than 404s. "phases": [ph for ph in ("primary", "secondary") if place.publishes_phase(ph)]}, - "schools": clean_for_json(_with_whole_school_pupils(rows[cols], rows)), + "schools": clean_for_json( + _with_type_group(_with_whole_school_pupils(rows[cols], rows))), "averages": averages, } diff --git a/backend/schemas.py b/backend/schemas.py index 005a815..0fd25e4 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -569,6 +569,7 @@ SCHOOL_COLUMNS = [ "religious_denomination", "age_range", "has_sixth_form", + "nursery_provision", "status", "gender", "admissions_policy", diff --git a/backend/tests/test_type_group_payload.py b/backend/tests/test_type_group_payload.py new file mode 100644 index 0000000..61f0f5f --- /dev/null +++ b/backend/tests/test_type_group_payload.py @@ -0,0 +1,81 @@ +"""Payloads name each school's type group. + +The school page and the search rows say "State school" or "Independent +school" in the search filter's own terms, not GIAS's 34 establishment types, +and an independent school gets a Fee-paying flag. A type in no group keeps a +null group, and the page prints the register's own name for it. Search rows +also carry nursery_provision, for their "Nursery class" flag. +""" + +import numpy as np +import pandas as pd +import pytest +from fastapi.testclient import TestClient + + +def _schools_df() -> pd.DataFrame: + base = { + "local_authority": "Essex", "phase": "Primary", "year": 202425, + "ofsted_grade": 2.0, "ofsted_date": None, "attainment_8_score": np.nan, + "town": "Brentwood", "postcode": "CM13 1AA", "status": "Open", + "address": "1 Test Street", "latitude": 51.6, "longitude": 0.3, + "rwm_expected_pct": 60.0, "nursery_provision": "No Nursery Classes", + } + rows = [ + {**base, "urn": 100001, "school_name": "Alpha Academy", + "school_type": "Academy converter", "nursery_provision": "Has Nursery Classes"}, + {**base, "urn": 100002, "school_name": "Beta Prep", + "school_type": "Other independent school"}, + {**base, "urn": 100003, "school_name": "Gamma Unit", + "school_type": "Secure units"}, + ] + # Enough schools in one town for it to have a place page. + rows += [ + {**base, "urn": 100010 + i, "school_name": f"Delta Primary {i}", + "school_type": "Community school"} + for i in range(3) + ] + return pd.DataFrame(rows) + + +@pytest.fixture() +def client(monkeypatch): + from backend import app as app_module + + monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df) + monkeypatch.setattr(app_module, "load_school_data", _schools_df) + monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {}) + monkeypatch.setattr(app_module, "_place_registry", None) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def _by_urn(schools: list[dict]) -> dict[int, dict]: + return {s["urn"]: s for s in schools} + + +def test_the_list_names_each_type_group(client): + resp = client.get("/api/schools?page_size=50") + assert resp.status_code == 200, resp.text + schools = _by_urn(resp.json()["schools"]) + assert schools[100001]["type_group"] == "state" + assert schools[100002]["type_group"] == "independent" + assert schools[100003]["type_group"] is None + + +def test_the_list_carries_nursery_provision(client): + schools = _by_urn(client.get("/api/schools?page_size=50").json()["schools"]) + assert schools[100001]["nursery_provision"] == "Has Nursery Classes" + + +def test_the_school_page_names_its_type_group(client): + resp = client.get("/api/schools/100002") + assert resp.status_code == 200, resp.text + assert resp.json()["school_info"]["type_group"] == "independent" + + +def test_a_place_page_names_each_type_group(client): + resp = client.get("/api/places/town/brentwood") + assert resp.status_code == 200, resp.text + schools = _by_urn(resp.json()["schools"]) + assert schools[100001]["type_group"] == "state" + assert schools[100003]["type_group"] is None