feat(api): filter schools by type group and by faith

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-10-02 11:53:14 +01:00
1 parent 354244f755
commit 15b8923e60
2 files changed
+161 -3

No files matched your search

+61 -3
View File
@@ -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),
}
@@ -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"]