diff --git a/backend/app.py b/backend/app.py index a146f19..9b63008 100644 --- a/backend/app.py +++ b/backend/app.py @@ -416,10 +416,14 @@ async def get_schools( df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()] if admissions_policy: df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()] - if has_sixth_form == "yes": - df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)] - elif has_sixth_form == "no": - df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)] + # GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not + # yet populated by the pipeline) is treated as "no sixth form". + if has_sixth_form in ("yes", "no"): + if "has_sixth_form" in df_latest.columns: + flag = df_latest["has_sixth_form"].eq(True) + else: # DB predates the pipeline re-run โ€” fall back to age range + flag = df_latest["age_range"].str.contains("18", na=False) + df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag] # Include key result metrics for display on cards location_cols = ["latitude", "longitude"] @@ -596,6 +600,7 @@ async def get_school_details(request: Request, urn: int): "address": latest.get("address", ""), "religious_denomination": latest.get("religious_denomination", ""), "age_range": latest.get("age_range", ""), + "has_sixth_form": latest.get("has_sixth_form"), "latitude": latest.get("latitude"), "longitude": latest.get("longitude"), "phase": latest.get("phase"), diff --git a/backend/data_loader.py b/backend/data_loader.py index ab9337d..6221461 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -125,6 +125,7 @@ _MAIN_QUERY = text(""" s.religious_character AS religious_denomination, s.gender, s.age_range, + s.has_sixth_form, s.admissions_policy, s.capacity, s.total_pupils AS gias_total_pupils, diff --git a/backend/schemas.py b/backend/schemas.py index 6d46541..a757469 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -543,6 +543,7 @@ SCHOOL_COLUMNS = [ "postcode", "religious_denomination", "age_range", + "has_sixth_form", "gender", "admissions_policy", "ofsted_grade", diff --git a/backend/tests/test_sixth_form_flag.py b/backend/tests/test_sixth_form_flag.py new file mode 100644 index 0000000..fe1782a --- /dev/null +++ b/backend/tests/test_sixth_form_flag.py @@ -0,0 +1,97 @@ +"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 ยง3). + +The filter and payloads must use dim_school.has_sixth_form, not the old +age_range-contains-"18" substring heuristic. The key regression case is a +16-19 sixth-form college: flag true, but "16-19" contains no "18". +""" + +import numpy as np +import pandas as pd +import pytest +from fastapi.testclient import TestClient + + +def _schools_df() -> pd.DataFrame: + """Latest-year snapshot rows as produced by load_latest_school_data.""" + base = { + "local_authority": "Testshire", + "school_type": "Academy", + "phase": "Secondary", + "address": "1 Test Street", + "town": "Testtown", + "postcode": "TS1 1AA", + "religious_denomination": None, + "gender": "Mixed", + "admissions_policy": None, + "ofsted_grade": np.nan, + "ofsted_date": None, + "ofsted_framework": None, + "latitude": 51.5, + "longitude": -0.1, + "year": 202425, + "total_pupils": 1000, + "rwm_expected_pct": np.nan, + "attainment_8_score": 50.0, + } + return pd.DataFrame( + [ + # 11-18 school WITH a registered sixth form + {**base, "urn": 100001, "school_name": "Alpha High", + "age_range": "11-18", "has_sixth_form": True}, + # 16-19 college: old heuristic said NO ("16-19" has no "18"), + # GIAS flag says YES โ€” must appear in the yes-filter results + {**base, "urn": 100002, "school_name": "Beta Sixth Form College", + "age_range": "16-19", "has_sixth_form": True}, + # 11-18 age range on paper but NO registered sixth form: + # old heuristic said YES, GIAS flag says NO + {**base, "urn": 100003, "school_name": "Gamma Academy", + "age_range": "11-18", "has_sixth_form": False}, + # Missing flag (pipeline not yet re-run) โ€” must not crash, + # must not match the yes-filter + {**base, "urn": 100004, "school_name": "Delta School", + "age_range": "11-16", "has_sixth_form": None}, + ] + ) + + +@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: {}) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def _urns(resp): + return sorted(s["urn"] for s in resp.json()["schools"]) + + +def test_filter_yes_uses_flag_not_age_range(client): + resp = client.get("/api/schools?has_sixth_form=yes") + assert resp.status_code == 200, resp.text + # 16-19 college included; 11-18-without-sixth-form excluded + assert _urns(resp) == [100001, 100002] + + +def test_filter_no_uses_flag_not_age_range(client): + resp = client.get("/api/schools?has_sixth_form=no") + assert resp.status_code == 200, resp.text + # Gamma (flag false) and Delta (flag missing => not true) + assert _urns(resp) == [100003, 100004] + + +def test_list_payload_includes_flag(client): + resp = client.get("/api/schools") + assert resp.status_code == 200, resp.text + by_urn = {s["urn"]: s for s in resp.json()["schools"]} + assert by_urn[100002]["has_sixth_form"] is True + assert by_urn[100003]["has_sixth_form"] is False + assert by_urn[100004]["has_sixth_form"] is None + + +def test_detail_payload_includes_flag(client): + resp = client.get("/api/schools/100002") + assert resp.status_code == 200, resp.text + assert resp.json()["school_info"]["has_sixth_form"] is True