feat(api): serve similar schools on the detail endpoint
Rides in the existing payload rather than a new endpoint: the page already makes one server fetch for its data, and /school/[slug] regenerates weekly, so the per-request cost is paid once per school per week. Wrapped so a failure in selection never 500s a page that is otherwise complete — the posture get_supplementary_data already takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b571d9c549
commit
52b00ac752
2 files changed
+74
No files matched your search
@@ -41,6 +41,7 @@ from .data_loader import get_data_info as get_db_info
|
|||||||
from . import flags
|
from . import flags
|
||||||
from .places import build_place_index, build_place_registry, places_for_urn
|
from .places import build_place_index, build_place_registry, places_for_urn
|
||||||
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
|
from .similar_schools import select_similar
|
||||||
from .utils import clean_for_json, convert_to_native
|
from .utils import clean_for_json, convert_to_native
|
||||||
|
|
||||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||||
@@ -265,6 +266,28 @@ def _places_payload(urn: int) -> list[dict]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _similar_schools_payload(urn: int, phase: str | None) -> list[dict]:
|
||||||
|
"""Nearby schools this page may offer as alternatives.
|
||||||
|
|
||||||
|
Wrapped: a failure in selection must never 500 a page that is otherwise
|
||||||
|
complete, which is the posture get_supplementary_data already takes. The
|
||||||
|
section simply does not render.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
phase_text = (phase or "").lower()
|
||||||
|
# All-through schools render with the primary template, which is what
|
||||||
|
# decides the metric, so they are not secondary here.
|
||||||
|
is_secondary = phase_text != "all-through" and "secondary" in phase_text
|
||||||
|
return select_similar(load_latest_school_data(), int(urn), is_secondary)
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
"Similar schools selection failed for urn=%s", urn
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _place_sitemap_rows(kinds: tuple[str, ...], registry=None) -> list[str]:
|
def _place_sitemap_rows(kinds: tuple[str, ...], registry=None) -> list[str]:
|
||||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
"""A <url> per place, plus a phase variant wherever that phase clears the
|
||||||
threshold on its own.
|
threshold on its own.
|
||||||
@@ -975,6 +998,11 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
# and authority both fall below the publish threshold has nowhere to
|
# and authority both fall below the publish threshold has nowhere to
|
||||||
# point, and the page renders without the module.
|
# point, and the page renders without the module.
|
||||||
"places": _places_payload(urn),
|
"places": _places_payload(urn),
|
||||||
|
# Nearby schools of the same phase and a comparable intake. Always
|
||||||
|
# present on a build with this code; the frontend treats absent and
|
||||||
|
# empty identically, which is what lets the two images deploy
|
||||||
|
# independently.
|
||||||
|
"similar_schools": _similar_schools_payload(urn, latest.get("phase")),
|
||||||
"yearly_data": clean_for_json(school_data),
|
"yearly_data": clean_for_json(school_data),
|
||||||
# Supplementary data (null if not yet populated by Kestra)
|
# Supplementary data (null if not yet populated by Kestra)
|
||||||
"ofsted": supplementary.get("ofsted"),
|
"ofsted": supplementary.get("ofsted"),
|
||||||
|
|||||||
@@ -250,3 +250,49 @@ def test_values_are_json_safe_native_types():
|
|||||||
assert isinstance(school["urn"], int)
|
assert isinstance(school["urn"], int)
|
||||||
assert isinstance(school["distance_miles"], float)
|
assert isinstance(school["distance_miles"], float)
|
||||||
assert not isinstance(school["metric_value"], np.generic)
|
assert not isinstance(school["metric_value"], np.generic)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_frame():
|
||||||
|
return _frame(
|
||||||
|
_row(100001, "Subject Primary"),
|
||||||
|
_row(100002, "Neighbour A", latitude=_at(0.5)),
|
||||||
|
_row(100003, "Neighbour B", latitude=_at(0.6)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "load_latest_school_data", _endpoint_frame)
|
||||||
|
monkeypatch.setattr(app_module, "load_school_data", _endpoint_frame)
|
||||||
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_payload_carries_similar_schools(client):
|
||||||
|
resp = client.get("/api/schools/100001")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
similar = resp.json()["similar_schools"]
|
||||||
|
assert [s["school_name"] for s in similar] == ["Neighbour A", "Neighbour B"]
|
||||||
|
assert similar[0]["metric_key"] == "rwm_expected_pct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failure_in_selection_does_not_break_the_page(client, monkeypatch):
|
||||||
|
from backend import app as app_module
|
||||||
|
|
||||||
|
def _explode(*args, **kwargs):
|
||||||
|
raise ValueError("selection blew up")
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "select_similar", _explode)
|
||||||
|
resp = client.get("/api/schools/100001")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["similar_schools"] == []
|
||||||
Reference in new issue
Block a user