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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user