diff --git a/backend/app.py b/backend/app.py index 59d79b8..a8d571a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -41,6 +41,14 @@ from .data_loader import get_data_info as get_db_info from . import flags from .places import build_place_index, build_place_registry, places_for_urn from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS +from .school_groups import ( + FAITH_GROUPS, + FAITH_KEYS, + TYPE_GROUP_KEYS, + TYPE_GROUPS, + faith_groups_for, + type_group_for, +) from .nearby_schools import select_nearby from .utils import clean_for_json, convert_to_native @@ -608,6 +616,15 @@ def verify_admin_api_key(x_api_key: str = Header(None)) -> bool: # Input validation helpers +def _names_in_group(names: pd.Series, in_group) -> set: + """The distinct names in a column that a group predicate accepts. + + Evaluated once per distinct name rather than per row, so a filter over + every school costs a few dozen lookups. + """ + return {n for n in names.dropna().unique() if in_group(n)} + + def sanitize_search_input(value: Optional[str], max_length: int = 100) -> Optional[str]: """Sanitize search input to prevent injection attacks.""" if value is None: @@ -744,6 +761,7 @@ async def get_schools( gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50), admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100), has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3), + faith: Optional[str] = Query(None, description="Filter by faith group key", max_length=40), ): """ Get list of schools with pagination. @@ -756,6 +774,7 @@ async def get_schools( local_authority = sanitize_search_input(local_authority) school_type = sanitize_search_input(school_type) phase = sanitize_search_input(phase) + faith = sanitize_search_input(faith) postcode = validate_postcode(postcode) # Load the pre-computed latest-year snapshot (cached after first request / startup). @@ -796,6 +815,22 @@ async def get_schools( flag = df_latest["age_range"].str.contains("18", na=False) df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag] + # Faith group (backend/school_groups.py). A joint school is in every faith + # its label names; a missing religious character is "none". An unknown key + # matches nothing rather than being ignored, so a typo cannot show all. + if faith: + faith_key = faith.lower() + if faith_key in FAITH_KEYS and "religious_denomination" in df_latest.columns: + column = df_latest["religious_denomination"] + matches = column.isin(_names_in_group(column, lambda n: faith_key in faith_groups_for(n))) + # _names_in_group skips missing names; a missing religious + # character is "No religious character". + if faith_key == "none": + matches = matches | column.isna() + df_latest = df_latest[matches] + else: + df_latest = df_latest.iloc[0:0] + # Include key result metrics for display on cards location_cols = ["latitude", "longitude"] result_cols = [ @@ -883,10 +918,17 @@ async def get_schools( schools_df["local_authority"].str.lower() == local_authority.lower() ] + # A type group key (backend/school_groups.py), or for an old link a raw + # GIAS type label, matched exactly as before. if school_type: - schools_df = schools_df[ - schools_df["school_type"].str.lower() == school_type.lower() - ] + type_key = school_type.lower() + if type_key in TYPE_GROUP_KEYS: + column = schools_df["school_type"] + schools_df = schools_df[ + column.isin(_names_in_group(column, lambda n: type_group_for(n) == type_key)) + ] + else: + schools_df = schools_df[schools_df["school_type"].str.lower() == type_key] # Compute result-scoped filter values (before pagination). # Gender and admissions are secondary-only filters — scope them to schools @@ -1161,6 +1203,8 @@ async def get_filter_options(request: Request): "local_authorities": [], "school_types": [], "years": [], + "school_type_groups": [], + "faiths": [], } # Phases: return values from data, ordered sensibly @@ -1170,6 +1214,18 @@ async def get_filter_options(request: Request): genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else [] admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else [] + def offered(groups, present): + return [{"value": key, "label": label} for key, label, _ in groups if key in present] + + type_groups_present = ( + {type_group_for(n) for n in df["school_type"].dropna().unique()} - {None} + if "school_type" in df.columns else set() + ) + faiths_present = ( + {f for n in df["religious_denomination"].unique() for f in faith_groups_for(n)} + if "religious_denomination" in df.columns else set() + ) + return { "local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [], "school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [], @@ -1177,6 +1233,8 @@ async def get_filter_options(request: Request): "phases": phases, "genders": genders, "admissions_policies": admissions_policies, + "school_type_groups": offered(TYPE_GROUPS, type_groups_present), + "faiths": offered(FAITH_GROUPS, faiths_present), } diff --git a/backend/tests/test_type_and_faith_filters.py b/backend/tests/test_type_and_faith_filters.py new file mode 100644 index 0000000..00efe57 --- /dev/null +++ b/backend/tests/test_type_and_faith_filters.py @@ -0,0 +1,100 @@ +"""/api/schools school-type groups and faith filter, and their /api/filters lists.""" + +import numpy as np +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +# urn -> (GIAS school type, GIAS religious character) +SCHOOLS = { + 100001: ("Community school", "Does not apply"), + 100002: ("Voluntary aided school", "Roman Catholic"), + 100003: ("Academy converter", "Roman Catholic/Church of England"), + 100004: ("Community special school", None), + 100005: ("Academy special converter", "Church of England"), + 100006: ("Other independent school", "Jewish"), + 100007: ("Miscellaneous", ""), +} + + +def _schools_df() -> pd.DataFrame: + base = { + "local_authority": "Testshire", "address": "1 Test Street", "town": "Testtown", + "postcode": "TS1 1AA", "age_range": "4-11", "has_sixth_form": 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": 300, + "rwm_expected_pct": np.nan, "attainment_8_score": np.nan, "phase": "Primary", + } + return pd.DataFrame([ + {**base, "urn": urn, "school_name": f"School {urn}", + "school_type": t, "religious_denomination": r} + for urn, (t, r) in SCHOOLS.items() + ]) + + +@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) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def _urns(client, **params): + resp = client.get("/api/schools", params={"page_size": 50, **params}) + assert resp.status_code == 200, resp.text + return sorted(s["urn"] for s in resp.json()["schools"]) + + +@pytest.mark.parametrize("key, urns", [ + ("council", [100001, 100002]), + ("academy", [100003]), + ("special", [100004, 100005]), + ("independent", [100006]), + ("Special", [100004, 100005]), +]) +def test_a_type_group_key_filters_to_its_group(client, key, urns): + assert _urns(client, school_type=key) == urns + + +def test_a_raw_type_label_still_filters_exactly(client): + assert _urns(client, school_type="Community school") == [100001] + + +@pytest.mark.parametrize("key, urns", [ + ("roman_catholic", [100002, 100003]), + ("church_of_england", [100003, 100005]), + ("none", [100001, 100004, 100007]), + ("jewish", [100006]), + ("Roman_Catholic", [100002, 100003]), +]) +def test_faith_filters_to_its_group_joint_schools_included(client, key, urns): + assert _urns(client, faith=key) == urns + + +def test_an_unknown_faith_returns_nothing(client): + assert _urns(client, faith="nonsense") == [] + + +def test_type_and_faith_combine(client): + assert _urns(client, school_type="special", faith="church_of_england") == [100005] + + +def test_filters_lists_only_groups_present_in_order(client): + body = client.get("/api/filters").json() + assert body["school_type_groups"] == [ + {"value": "academy", "label": "State school: academy or free school"}, + {"value": "council", "label": "State school: council-run"}, + {"value": "independent", "label": "Independent (fee-paying)"}, + {"value": "special", "label": "Special school (SEND)"}, + ] + assert body["faiths"] == [ + {"value": "none", "label": "No religious character"}, + {"value": "church_of_england", "label": "Church of England"}, + {"value": "roman_catholic", "label": "Roman Catholic"}, + {"value": "jewish", "label": "Jewish"}, + ] + # The raw list is still there for anything that reads it. + assert "Community school" in body["school_types"]