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 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-10-02 22:59:45 +01:00
1 parent e344298440
commit 62a6bfaf0c
3 files changed
+102 -7

No files matched your search

+20 -7
View File
@@ -634,6 +634,18 @@ def _with_whole_school_pupils(rows: pd.DataFrame, source: pd.DataFrame) -> pd.Da
return rows.assign(total_pupils=whole) 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 # Input validation helpers
def _names_in_group(names: pd.Series, in_group) -> set: def _names_in_group(names: pd.Series, in_group) -> set:
"""The distinct names in a column that a group predicate accepts. """The distinct names in a column that a group predicate accepts.
@@ -965,7 +977,7 @@ async def get_schools(
total = len(schools_df) total = len(schools_df)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
end_idx = start_idx + 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 { return {
"schools": clean_for_json(schools_df), "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", ""), "school_name": latest.get("school_name", ""),
"local_authority": latest.get("local_authority", ""), "local_authority": latest.get("local_authority", ""),
"school_type": latest.get("school_type", ""), "school_type": latest.get("school_type", ""),
"type_group": type_group_for(latest.get("school_type")),
"address": latest.get("address", ""), "address": latest.get("address", ""),
"religious_denomination": latest.get("religious_denomination", ""), "religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""), "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 # warning. Ordered de-duplication keeps the column order and the warning
# cannot come back. # cannot come back.
# #
# nursery_provision and parliamentary_constituency are not in # parliamentary_constituency is not in SCHOOL_COLUMNS and the place table
# SCHOOL_COLUMNS and the place table shows both. The `in rows.columns` # shows it. The `in rows.columns` guard is what keeps a mart the pipeline
# guard is what keeps a mart the pipeline has not rebuilt working: those # has not rebuilt working: it and nursery_provision are the optional GIAS
# two are the optional GIAS columns data_loader degrades to NULL. # columns data_loader degrades to NULL.
cols = [c for c in dict.fromkeys( cols = [c for c in dict.fromkeys(
SCHOOL_COLUMNS + ["latitude", "longitude", "phase", SCHOOL_COLUMNS + ["latitude", "longitude", "phase",
"nursery_provision",
"parliamentary_constituency", "parliamentary_constituency",
"rwm_expected_pct", "attainment_8_score", "rwm_expected_pct", "attainment_8_score",
"total_pupils"]) "total_pupils"])
@@ -1558,7 +1570,8 @@ async def get_place(request: Request, kind: str, slug: str,
# variants that exist rather than 404s. # variants that exist rather than 404s.
"phases": [ph for ph in ("primary", "secondary") "phases": [ph for ph in ("primary", "secondary")
if place.publishes_phase(ph)]}, 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, "averages": averages,
} }
+1
View File
@@ -569,6 +569,7 @@ SCHOOL_COLUMNS = [
"religious_denomination", "religious_denomination",
"age_range", "age_range",
"has_sixth_form", "has_sixth_form",
"nursery_provision",
"status", "status",
"gender", "gender",
"admissions_policy", "admissions_policy",
+81
View File
@@ -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