diff --git a/backend/app.py b/backend/app.py index 7997cd5..cc79cbd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -41,6 +41,7 @@ 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 .similar_schools import select_similar from .utils import clean_for_json, convert_to_native # Values to exclude from filter dropdowns (empty strings, non-applicable labels) @@ -265,6 +266,28 @@ def _places_payload(urn: int) -> list[dict]: 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]: """A per place, plus a phase variant wherever that phase clears the 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 # point, and the page renders without the module. "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), # Supplementary data (null if not yet populated by Kestra) "ofsted": supplementary.get("ofsted"), diff --git a/backend/tests/test_similar_schools.py b/backend/tests/test_similar_schools.py index aab8de7..356902a 100644 --- a/backend/tests/test_similar_schools.py +++ b/backend/tests/test_similar_schools.py @@ -250,3 +250,49 @@ def test_values_are_json_safe_native_types(): assert isinstance(school["urn"], int) assert isinstance(school["distance_miles"], float) 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"] == []