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),
}