From f1a013ec014cb1599d543196135c8775fc4b2241 Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 9 Jul 2026 10:48:53 +0100 Subject: [PATCH] feat(api): translate GIAS codes to names at the query boundary Co-Authored-By: Claude Fable 5 --- backend/data_loader.py | 44 +++++++++++++++++++++++--- backend/models.py | 10 +++--- backend/tests/test_gias_translation.py | 44 ++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_gias_translation.py diff --git a/backend/data_loader.py b/backend/data_loader.py index c753d1a..db72748 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -21,6 +21,38 @@ from .models import ( FactDeprivation, FactFinance, FactPupilCharacteristics, ) from .schemas import SCHOOL_TYPE_MAP +from .gias_codes import ( + ADMISSIONS_POLICY, + ESTABLISHMENT_STATUS, + PHASE_OF_EDUCATION, + RELIGIOUS_CHARACTER, + SCHOOL_TYPE, + translate, +) + +# mart code column -> (API name column, dictionary) +_GIAS_CODE_COLUMNS = { + "phase_code": ("phase", PHASE_OF_EDUCATION), + "school_type_code": ("school_type", SCHOOL_TYPE), + "status_code": ("status", ESTABLISHMENT_STATUS), + "religious_character_code": ("religious_denomination", RELIGIOUS_CHARACTER), + "admissions_policy_code": ("admissions_policy", ADMISSIONS_POLICY), +} + + +def translate_gias_code_columns(df: pd.DataFrame) -> pd.DataFrame: + """Map GIAS code columns to today's name columns (API contract). + + Runs immediately after pd.read_sql so every downstream consumer — + filters, PHASE_GROUPS, payloads, /api/filters — keeps seeing names. + DataFrames without the code columns (old schema, test fixtures) pass + through unchanged. + """ + for code_col, (name_col, mapping) in _GIAS_CODE_COLUMNS.items(): + if code_col in df.columns: + df[name_col] = df[code_col].map(lambda c: translate(c, mapping)) + return df + _postcode_cache: Dict[str, Tuple[float, float]] = {} _typesense_client = None @@ -121,16 +153,16 @@ _MAIN_QUERY = text(""" SELECT s.urn, s.school_name, - s.phase, - s.school_type, + s.phase_code, + s.school_type_code, s.academy_trust_name AS trust_name, s.academy_trust_uid AS trust_uid, - s.religious_character AS religious_denomination, + s.religious_character_code, s.gender, s.age_range, s.has_sixth_form, - s.status, - s.admissions_policy, + s.status_code, + s.admissions_policy_code, s.capacity, s.total_pupils AS gias_total_pupils, s.headteacher_name, @@ -256,6 +288,8 @@ def load_school_data_as_dataframe() -> pd.DataFrame: if df.empty: return df + df = translate_gias_code_columns(df) + # Build address string df["address"] = df.apply( lambda r: ", ".join( diff --git a/backend/models.py b/backend/models.py index a83bcb7..2ab54e3 100644 --- a/backend/models.py +++ b/backend/models.py @@ -17,11 +17,11 @@ class DimSchool(Base): urn = Column(Integer, primary_key=True) school_name = Column(String(255), nullable=False) - phase = Column(String(100)) - school_type = Column(String(100)) + phase_code = Column(Integer) + school_type_code = Column(Integer) academy_trust_name = Column(String(255)) academy_trust_uid = Column(String(20)) - religious_character = Column(String(100)) + religious_character_code = Column(Integer) gender = Column(String(20)) age_range = Column(String(20)) has_sixth_form = Column(Boolean) @@ -30,9 +30,9 @@ class DimSchool(Base): headteacher_name = Column(String(200)) website = Column(String(255)) telephone = Column(String(30)) - status = Column(String(50)) + status_code = Column(Integer) nursery_provision = Column(Boolean) - admissions_policy = Column(String(50)) + admissions_policy_code = Column(Integer) # Denormalised Ofsted summary (updated by monthly pipeline) ofsted_grade = Column(Integer) ofsted_date = Column(Date) diff --git a/backend/tests/test_gias_translation.py b/backend/tests/test_gias_translation.py new file mode 100644 index 0000000..7586010 --- /dev/null +++ b/backend/tests/test_gias_translation.py @@ -0,0 +1,44 @@ +"""API-boundary translation: marts now carry GIAS codes; the DataFrame the +rest of the backend sees must carry today's name strings.""" + +import numpy as np +import pandas as pd + +from backend.data_loader import translate_gias_code_columns +from backend.gias_codes import ESTABLISHMENT_STATUS, PHASE_OF_EDUCATION + + +def _code_for(mapping, name): + return next(c for c, n in mapping.items() if n == name) + + +def test_codes_become_todays_names(): + df = pd.DataFrame([{ + "urn": 1, + "phase_code": float(_code_for(PHASE_OF_EDUCATION, "Primary")), + "school_type_code": np.nan, + "status_code": float(_code_for(ESTABLISHMENT_STATUS, "Open, but proposed to close")), + "religious_character_code": np.nan, + "admissions_policy_code": np.nan, + }]) + out = translate_gias_code_columns(df) + row = out.iloc[0] + assert row["phase"] == "Primary" + assert row["status"] == "Open, but proposed to close" + assert row["school_type"] is None + assert row["religious_denomination"] is None + assert row["admissions_policy"] is None + + +def test_unknown_code_degrades_not_blanks(): + df = pd.DataFrame([{"urn": 1, "phase_code": 9999.0}]) + out = translate_gias_code_columns(df) + assert out.iloc[0]["phase"] == "Unknown (9999)" + + +def test_missing_code_columns_are_a_noop(): + """Old-schema DataFrames (tests, pre-pipeline DBs) pass through untouched.""" + df = pd.DataFrame([{"urn": 1, "phase": "Primary", "status": "Open"}]) + out = translate_gias_code_columns(df) + assert out.iloc[0]["phase"] == "Primary" + assert out.iloc[0]["status"] == "Open"