PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m24s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m18s
W2 shipped ~5,000 place pages and nothing linked into them. The
location layer pointed down at school pages; school pages pointed
nowhere on the site. Their only anchor was the school's own website, so
the ~27k pages carrying most of the site's inbound authority passed it
straight off-site, and the new corpus was reachable mainly through the
sitemap.
Three things close the loop.
A reverse index over the place registry, places_for_urn, answers which
published places contain a school. Derived from the registry rather
than stored beside it, so the two cannot disagree about which places
exist: a place below the publish threshold is absent from the registry
and therefore never offered as a link. A test asserts that invariant
across every place in a built registry.
GET /api/schools/{urn} gains a `places` array carrying the name, count
and canonical path for each. It rides on the request the page already
makes, so the school page costs no extra round trip. The frontend types
it optional and defaults it to empty, because the two images deploy
separately and a frontend ahead of the API must render without it.
The page gains a "More schools near here" module and a BreadcrumbList.
The module orders narrowest first, because a reader on a school page
wants its town before its county, while the API orders widest first for
the trail. Anchors state their destination's size — "37 schools in
Brentwood" — which is worth more to a reader and a crawler than "see
more". With no published places it renders nothing rather than an empty
heading.
The trail is rooted at the homepage, not /schools. There is no /schools
index page; the location layer lives only at /schools/[place],
/schools/authority/[la] and /schools/near/[outcode]. Rooting it at the
bare path would have opened every breadcrumb with a link to a 404.
Outcodes are omitted from the trail: "schools near CM15" is a real
query and a useful link, but nobody navigates Essex to CM15 to a
school, and a breadcrumb claiming that describes a hierarchy the site
does not have.
School pages also now declare the School type rather than
EducationalOrganization, the parent type that covers universities and
nurseries alike.
The e2e journey asserts the round trip in both directions, following a
place page's own first school so the pair is genuinely related rather
than hardcoded. A one-way link is what already existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXnXQKnPpZBBP61fBQiFkq
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""The place registry: what places the site publishes, and what is in each.
|
|
|
|
One module owns this question. The pages, the sitemap and the internal-link
|
|
modules all read from here, so the threshold and the collision rules exist in
|
|
exactly one place and are testable without a browser or a database.
|
|
|
|
Two namespaces, never one. 67 viable town names collide with a local
|
|
authority name, and the authority is the larger set in only 43 of them —
|
|
postal towns cross authority boundaries, so neither can absorb the other.
|
|
Keys are "<kind>:<slug>" so the collision cannot reappear in the dict.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Five schools with publishable data. Below this a place has nothing to say
|
|
# that a list of schools does not, and publishing it is index bloat.
|
|
MIN_SCHOOLS = 5
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Place:
|
|
kind: str # "town" | "locality" | "authority" | "outcode"
|
|
slug: str
|
|
name: str
|
|
urns: tuple[int, ...]
|
|
parent_authority: str | None # authority NAME, for the 301 target
|
|
# Every authority the place meaningfully sits in, largest first. A quarter
|
|
# of outcodes and a third of towns straddle a boundary — SW19 is mostly
|
|
# Merton but partly Wandsworth — so naming only one asserts something
|
|
# false. parent_authority stays single because a redirect needs one
|
|
# target; this is what the page shows.
|
|
authorities: tuple[tuple[str, int], ...] = ()
|
|
# URNs per phase, so the per-phase threshold can be applied without
|
|
# re-querying. A place with 30 primaries and 2 secondaries publishes a
|
|
# primary variant and no secondary one.
|
|
phase_urns: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
|
|
|
def publishes_phase(self, phase: str) -> bool:
|
|
return len(self.phase_urns.get(phase, ())) >= MIN_SCHOOLS
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
return f"{self.kind}:{self.slug}"
|
|
|
|
|
|
def _publishable_urns(df) -> set[int]:
|
|
"""URNs with something a page could state, deduplicated across years."""
|
|
from backend.app import _PUBLISHABLE_FIELDS
|
|
|
|
cols = [c for c in _PUBLISHABLE_FIELDS if c in df.columns]
|
|
if not cols:
|
|
return set()
|
|
return set(df.loc[df[cols].notna().any(axis=1), "urn"].astype(int))
|
|
|
|
|
|
# The measure a phase page is built around. A page with no results in this
|
|
# column has nothing a list of school names does not already give.
|
|
_PHASE_METRIC = {
|
|
"primary": "rwm_expected_pct",
|
|
"secondary": "attainment_8_score",
|
|
}
|
|
|
|
|
|
def _phase_urns(group, publishable: set[int]) -> dict[str, tuple[int, ...]]:
|
|
"""URNs per phase, counting only schools with a result for that phase.
|
|
|
|
Not merely "publishable". A school with an Ofsted grade and no results is
|
|
worth a page of its own and belongs in the place list, but it cannot
|
|
populate a phase page's results column — and the threshold is there to ask
|
|
whether that column will have anything in it.
|
|
|
|
Counting publishable schools instead let /schools/kent/primary publish
|
|
with none of its five rows carrying a result, and left 44 phase pages
|
|
majority-blank. It is the same rule as "no page without a local average",
|
|
which was never extended per phase.
|
|
|
|
All-through schools count toward both phases, matching the PHASE_GROUPS
|
|
mapping the search filters already use.
|
|
"""
|
|
from backend.app import PHASE_GROUPS
|
|
|
|
if "phase" not in group.columns:
|
|
return {}
|
|
lowered = group["phase"].fillna("").str.lower()
|
|
|
|
out: dict[str, tuple[int, ...]] = {}
|
|
for phase in ("primary", "secondary"):
|
|
wanted = PHASE_GROUPS.get(phase, set())
|
|
subset = group[lowered.isin(wanted)]
|
|
|
|
# The page lists every school of the phase; the threshold counts only
|
|
# those carrying a result, so a mostly-empty table never publishes.
|
|
metric = _PHASE_METRIC[phase]
|
|
with_result = (
|
|
{int(u) for u in subset.loc[subset[metric].notna(), "urn"]}
|
|
if metric in subset.columns else set()
|
|
)
|
|
if len(with_result & publishable) < MIN_SCHOOLS:
|
|
continue
|
|
|
|
urns = tuple(sorted({int(u) for u in subset["urn"]} & publishable))
|
|
if urns:
|
|
out[phase] = urns
|
|
return out
|
|
|
|
|
|
# A place is described by an authority when it holds at least a tenth of the
|
|
# schools, and at least two. GIAS carries occasional postcode errors — EN6
|
|
# lists two Shropshire schools among fourteen in Hertfordshire — and a bare
|
|
# "any authority present" rule would print those as though they were real.
|
|
# There is deliberately no cap on how many are named. An earlier cut stopped
|
|
# at three, which silently dropped the fourth in exactly the case where the
|
|
# information matters most — a genuinely fragmented place. The share rule is
|
|
# the only limit, and it already bounds the list at ten.
|
|
_AUTHORITY_MIN_SHARE = 0.10
|
|
_AUTHORITY_MIN_SCHOOLS = 2
|
|
|
|
|
|
def _authorities(group) -> tuple[tuple[str, int], ...]:
|
|
"""Authorities this place meaningfully sits in, largest first."""
|
|
from backend.app import EXCLUDED_FILTER_VALUES
|
|
|
|
if "local_authority" not in group.columns:
|
|
return ()
|
|
counts = group["local_authority"].dropna().value_counts()
|
|
total = int(counts.sum())
|
|
if not total:
|
|
return ()
|
|
|
|
kept = [
|
|
(str(name), int(n)) for name, n in counts.items()
|
|
if str(name) not in EXCLUDED_FILTER_VALUES
|
|
and n >= _AUTHORITY_MIN_SCHOOLS
|
|
and n / total >= _AUTHORITY_MIN_SHARE
|
|
]
|
|
# A place too small or too fragmented for the share rule still names its
|
|
# largest authority, or the page would say nothing about where it is.
|
|
if not kept:
|
|
for name, n in counts.items():
|
|
if str(name) not in EXCLUDED_FILTER_VALUES:
|
|
return ((str(name), int(n)),)
|
|
return ()
|
|
return tuple(kept)
|
|
|
|
|
|
def _parent_authority(authorities: tuple[tuple[str, int], ...]) -> str | None:
|
|
"""The 301 target: the largest authority a place sits in.
|
|
|
|
Derived from `authorities` rather than computed separately. The first cut
|
|
used `mode()` here while `authorities` used `value_counts()`, and on an
|
|
exact tie pandas does not guarantee the two pick the same name — so the
|
|
redirect could have pointed somewhere other than the authority the page
|
|
named first. One computation, one answer.
|
|
|
|
Deriving it also inherits the sentinel filter, so a place can no longer
|
|
redirect to /schools/authority/does-not-apply.
|
|
"""
|
|
return authorities[0][0] if authorities else None
|
|
|
|
|
|
def _group(df, column: str, kind: str, publishable: set[int]) -> dict[str, Place]:
|
|
"""One Place per distinct SLUG in `column` that clears the threshold.
|
|
|
|
Grouped by slug, not by raw value, because GIAS spells the same place
|
|
several ways and they all resolve to one URL. Five town slugs come from
|
|
more than one spelling: "London" (1,819 schools) and "LONDON" (12) both
|
|
slugify to `london`; Weston-super-Mare is split 14/19 across two
|
|
spellings; Newcastle-under-Lyme across three.
|
|
|
|
Grouping by raw value meant the later group simply overwrote the earlier
|
|
one in this dict — so /schools/london could have shown twelve schools
|
|
instead of 1,819, silently and depending on row order.
|
|
|
|
The display name is the most common spelling, which is the one a reader
|
|
expects to see.
|
|
"""
|
|
from backend.app import _slugify
|
|
|
|
if column not in df.columns:
|
|
return {}
|
|
|
|
working = df.assign(_slug=df[column].map(
|
|
lambda v: _slugify(str(v).strip()) if isinstance(v, str) and v.strip() else None))
|
|
working = working[working["_slug"].notna() & (working["_slug"] != "")]
|
|
|
|
out: dict[str, Place] = {}
|
|
for slug, group in working.groupby("_slug"):
|
|
slug = str(slug)
|
|
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
|
if len(urns) < MIN_SCHOOLS:
|
|
continue
|
|
spellings = group[column].dropna().value_counts()
|
|
if spellings.empty:
|
|
continue
|
|
name = str(spellings.index[0]).strip()
|
|
authorities = () if kind == "authority" else _authorities(group)
|
|
place = Place(
|
|
kind=kind, slug=slug, name=name, urns=urns,
|
|
parent_authority=_parent_authority(authorities),
|
|
authorities=authorities,
|
|
phase_urns=_phase_urns(group, publishable),
|
|
)
|
|
out[place.key] = place
|
|
return out
|
|
|
|
|
|
# "SW11 2AA" -> "SW11". Two letters max, one or two digits, optional letter.
|
|
_OUTCODE_RE = re.compile(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")
|
|
|
|
|
|
def _outcode(postcode) -> str | None:
|
|
if not isinstance(postcode, str):
|
|
return None
|
|
m = _OUTCODE_RE.match(postcode.upper().strip())
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def _outcode_places(df, publishable: set[int]) -> dict[str, Place]:
|
|
"""One Place per postcode district clearing the threshold.
|
|
|
|
These carry no phase variants: nobody searches "primary schools in SW11",
|
|
so the spec gives them no /primary or /secondary route. `phase_urns` is
|
|
left empty rather than computed and then filtered downstream — the
|
|
registry is the one place that decides which phases a place publishes,
|
|
and the page links whatever it reports.
|
|
|
|
Computing them here put a link to a route that does not exist on every one
|
|
of the 1,720 outcode pages.
|
|
"""
|
|
if "postcode" not in df.columns:
|
|
return {}
|
|
working = df.assign(_oc=df["postcode"].map(_outcode))
|
|
working = working[working["_oc"].notna()]
|
|
|
|
out: dict[str, Place] = {}
|
|
for oc, group in working.groupby("_oc"):
|
|
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
|
if len(urns) < MIN_SCHOOLS:
|
|
continue
|
|
authorities = _authorities(group)
|
|
place = Place(kind="outcode", slug=str(oc).lower(), name=str(oc),
|
|
urns=urns, parent_authority=_parent_authority(authorities),
|
|
authorities=authorities)
|
|
out[place.key] = place
|
|
return out
|
|
|
|
|
|
def _locality_places(df, publishable: set[int],
|
|
town_slugs: set[str]) -> dict[str, Place]:
|
|
"""One Place per curated locality clearing the threshold."""
|
|
from backend.localities import LOCALITY_OUTCODES
|
|
|
|
if "postcode" not in df.columns:
|
|
return {}
|
|
working = df.assign(_oc=df["postcode"].map(_outcode))
|
|
|
|
out: dict[str, Place] = {}
|
|
for slug, (name, outcodes) in LOCALITY_OUTCODES.items():
|
|
if slug in town_slugs:
|
|
# Skip, do not raise. The guard exists so a locality never
|
|
# silently shadows a town — skipping achieves that, and the error
|
|
# log makes it loud.
|
|
#
|
|
# Raising here took down sitemap generation for all 25,000 school
|
|
# pages when "richmond" met the GIAS town Richmond in North
|
|
# Yorkshire. Worse, GIAS town names change without any code change,
|
|
# so a raise means curated data can break the site spontaneously.
|
|
# A curation mistake must cost one page, not the sitemap.
|
|
logger.error(
|
|
"locality %r collides with the published town of the same "
|
|
"slug and has been skipped; rename it or remove it", slug)
|
|
continue
|
|
group = working[working["_oc"].isin(outcodes)]
|
|
urns = tuple(sorted({int(u) for u in group["urn"]} & publishable))
|
|
if len(urns) < MIN_SCHOOLS:
|
|
# Not an error — a locality can legitimately be too small. Logged
|
|
# because one you meant to publish quietly vanishing is the
|
|
# failure worth hearing about.
|
|
logger.warning(
|
|
"locality %s (%s) has %d publishable schools, below the "
|
|
"threshold of %d - not published",
|
|
slug, ", ".join(outcodes), len(urns), MIN_SCHOOLS)
|
|
continue
|
|
authorities = _authorities(group)
|
|
place = Place(kind="locality", slug=slug, name=name, urns=urns,
|
|
parent_authority=_parent_authority(authorities),
|
|
authorities=authorities,
|
|
phase_urns=_phase_urns(group, publishable))
|
|
out[place.key] = place
|
|
return out
|
|
|
|
|
|
def places_for_urn(registry: dict[str, Place], urn: int) -> tuple[Place, ...]:
|
|
"""Every published place containing this school, largest kind first.
|
|
|
|
The reverse of the registry, and the thing school pages link out through.
|
|
Derived from the registry rather than stored beside it, so the two cannot
|
|
disagree about which places exist: a link module must never offer a place
|
|
whose page does not exist, and a place below the publish threshold is
|
|
simply absent from the registry, so it is absent from here too.
|
|
|
|
Ordered authority → town/locality → outcode, widest first, because that is
|
|
the order a breadcrumb reads and the order the link module lists.
|
|
"""
|
|
order = {"authority": 0, "town": 1, "locality": 2, "outcode": 3}
|
|
found = [p for p in registry.values() if urn in p.urns]
|
|
return tuple(sorted(found, key=lambda p: (order.get(p.kind, 9), p.slug)))
|
|
|
|
|
|
def build_place_registry(df) -> dict[str, Place]:
|
|
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
|
if df.empty or "urn" not in df.columns:
|
|
return {}
|
|
|
|
publishable = _publishable_urns(df)
|
|
registry: dict[str, Place] = {}
|
|
registry.update(_group(df, "local_authority", "authority", publishable))
|
|
|
|
towns = _group(df, "town", "town", publishable)
|
|
registry.update(towns)
|
|
|
|
town_slugs = {p.slug for p in towns.values()}
|
|
registry.update(_locality_places(df, publishable, town_slugs))
|
|
registry.update(_outcode_places(df, publishable))
|
|
return registry
|