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:
1 parent
354244f755
commit
15b8923e60
2 files changed
+161
-3
No files matched your search
+61
-3
@@ -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"]
|
||||
Reference in new issue
Block a user