diff --git a/backend/app.py b/backend/app.py index a3bfc4f..ce6d339 100644 --- a/backend/app.py +++ b/backend/app.py @@ -35,6 +35,7 @@ from .data_loader import ( search_schools_typesense, ) from .data_loader import get_data_info as get_db_info +from .places import build_place_registry from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS from .utils import clean_for_json, convert_to_native @@ -58,6 +59,12 @@ MAX_SLUG_LENGTH = 60 # regenerate endpoint after a pipeline run. _sitemaps: dict[str, str] | None = None +# Built from the same DataFrame the sitemap uses, so places and sitemap can +# never describe different corpora. Reset by the same admin endpoint. +_place_registry: dict | None = None + +VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode") + def _slugify(text: str) -> str: text = text.lower() @@ -170,6 +177,14 @@ SITEMAP_CHUNK_SIZE = 10_000 SITEMAP_CHILD_PREFIX = "/sitemaps" +def get_place_registry() -> dict: + """The place registry, built once and cached for the process.""" + global _place_registry + if _place_registry is None: + _place_registry = build_place_registry(load_school_data()) + return _place_registry + + def _urlset(rows: list[str]) -> str: return "\n".join([ '', @@ -1117,6 +1132,62 @@ async def get_rankings( } +@app.get("/api/places") +@limiter.limit(f"{settings.rate_limit_per_minute}/minute") +async def list_places(request: Request): + """Every published place. The sitemap and the link modules read this.""" + registry = get_place_registry() + return {"places": [ + {"kind": p.kind, "slug": p.slug, "name": p.name, "count": len(p.urns)} + for p in sorted(registry.values(), key=lambda p: (p.kind, p.slug)) + ]} + + +@app.get("/api/places/{kind}/{slug}") +@limiter.limit(f"{settings.rate_limit_per_minute}/minute") +async def get_place(request: Request, kind: str, slug: str, + phase: Optional[str] = None): + """One place: its schools ranked, and its local averages.""" + if kind not in VALID_PLACE_KINDS: + raise HTTPException(status_code=404, detail="No such place") + + place = get_place_registry().get(f"{kind}:{slug}") + if place is None: + raise HTTPException(status_code=404, detail="No such place") + + df = load_latest_school_data() + rows = df[df["urn"].isin(place.urns)] + + if phase: + wanted = PHASE_GROUPS.get(phase.lower()) + if wanted and "phase" in rows.columns: + rows = rows[rows["phase"].fillna("").str.lower().isin(wanted)] + + # The metric the page ranks on, which is also the one it averages. + metric = "attainment_8_score" if phase == "secondary" else "rwm_expected_pct" + if metric in rows.columns: + rows = rows.sort_values(metric, ascending=False, na_position="last") + + averages = { + m: (None if m not in rows.columns or rows[m].dropna().empty + else float(rows[m].dropna().mean())) + for m in ("rwm_expected_pct", "attainment_8_score") + } + + cols = [c for c in SCHOOL_COLUMNS + ["latitude", "longitude", "phase", + "rwm_expected_pct", "attainment_8_score", + "total_pupils"] + if c in rows.columns] + + return { + "place": {"kind": place.kind, "slug": place.slug, "name": place.name, + "count": len(place.urns), + "parent_authority": place.parent_authority}, + "schools": clean_for_json(rows[cols]), + "averages": averages, + } + + @app.get("/api/data-info") @limiter.limit(f"{settings.rate_limit_per_minute}/minute") async def get_data_info(request: Request): @@ -1228,7 +1299,11 @@ async def regenerate_sitemap( _: bool = Depends(verify_admin_api_key), ): """Rebuild and cache the sitemap from current school data. Called by Airflow after data updates.""" - global _sitemaps + global _sitemaps, _place_registry + # Places and sitemap are rebuilt together — they read the same marts, and + # letting them drift apart would submit URLs for places that no longer + # exist. + _place_registry = None _sitemaps = build_sitemaps() n = sum(x.count("") for x in _sitemaps.values()) return {"status": "ok", "urls": n, "sitemaps": len(_sitemaps)} diff --git a/backend/tests/test_places_api.py b/backend/tests/test_places_api.py new file mode 100644 index 0000000..4eb9822 --- /dev/null +++ b/backend/tests/test_places_api.py @@ -0,0 +1,71 @@ +"""Tests for the places API (spec 2026-08-21).""" + +import numpy as np +import pandas as pd +import pytest +from fastapi.testclient import TestClient + + +def _schools_df() -> pd.DataFrame: + base = { + "local_authority": "Essex", "school_type": "Academy", + "phase": "Primary", "year": 202425, "ofsted_grade": 2.0, + "ofsted_date": None, "attainment_8_score": np.nan, + "town": "Brentwood", "postcode": "CM13 1AA", "status": "Open", + "address": "1 Test Street", "latitude": 51.6, "longitude": 0.3, + } + return pd.DataFrame([ + {**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}", + "rwm_expected_pct": 50.0 + i} + for i in range(6) + ]) + + +@pytest.fixture() +def client(monkeypatch): + from backend import app as app_module + + monkeypatch.setattr(app_module, "load_school_data", _schools_df) + monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df) + monkeypatch.setattr(app_module, "_place_registry", None) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def test_registry_lists_each_published_place(client): + body = client.get("/api/places").json() + slugs = {(p["kind"], p["slug"]) for p in body["places"]} + assert ("town", "brentwood") in slugs + assert ("authority", "essex") in slugs + assert ("outcode", "cm13") in slugs + + +def test_registry_carries_a_count_per_place(client): + body = client.get("/api/places").json() + town = next(p for p in body["places"] if p["slug"] == "brentwood") + assert town["count"] == 6 + + +def test_place_detail_returns_its_schools_ranked(client): + body = client.get("/api/places/town/brentwood").json() + assert body["place"]["name"] == "Brentwood" + scores = [s["rwm_expected_pct"] for s in body["schools"]] + assert scores == sorted(scores, reverse=True) + + +def test_place_detail_carries_the_local_average(client): + body = client.get("/api/places/town/brentwood").json() + # 50..55 inclusive + assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5) + + +def test_phase_filter_narrows_the_school_list(client): + body = client.get("/api/places/town/brentwood?phase=secondary").json() + assert body["schools"] == [] + + +def test_unknown_place_404s(client): + assert client.get("/api/places/town/atlantis").status_code == 404 + + +def test_unknown_kind_404s(client): + assert client.get("/api/places/planet/mars").status_code == 404