Merge pull request 'feat(search): phases in the order a child meets them' (#174) from feat/phase-order-child-path into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 31s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 3m10s

Reviewed-on: #174
This commit was merged in pull request #174.
This commit is contained in:
tudor committed 2026-10-02 18:07:08 +00:00
commit dd5b48e612
4 files changed
+58 -4

No files matched your search

+10 -4
View File
@@ -40,7 +40,7 @@ from .data_loader import (
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 .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, PHASE_ORDER, RANKING_COLUMNS, SCHOOL_COLUMNS
from .school_groups import (
FAITH_GROUPS,
FAITH_KEYS,
@@ -381,6 +381,12 @@ def clean_filter_values(series: pd.Series) -> list[str]:
)
def order_phases(phases: list[str]) -> list[str]:
"""Phases in the order a child meets them; any GIAS adds later follow, A-Z."""
rank = {p: i for i, p in enumerate(PHASE_ORDER)}
return sorted(phases, key=lambda p: (rank.get(p.lower(), len(rank)), p))
# =============================================================================
# SECURITY MIDDLEWARE & HELPERS
# =============================================================================
@@ -937,7 +943,7 @@ async def get_schools(
result_filters = {
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
"phases": order_phases(clean_filter_values(schools_df["phase"])) if "phase" in schools_df.columns else [],
"genders": clean_filter_values(schools_df.loc[_sec_mask, "gender"]) if "gender" in schools_df.columns and _sec_mask.any() else [],
"admissions_policies": clean_filter_values(schools_df.loc[_sec_mask, "admissions_policy"]) if "admissions_policy" in schools_df.columns and _sec_mask.any() else [],
}
@@ -1207,8 +1213,8 @@ async def get_filter_options(request: Request):
"faiths": [],
}
# Phases: return values from data, ordered sensibly
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
# Phases: the values in the data, in the order a child meets them
phases = order_phases(clean_filter_values(df["phase"])) if "phase" in df.columns else []
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
+13
View File
@@ -544,6 +544,19 @@ PHASE_GROUPS: dict[str, set[str]] = {
"all-through": {"all-through"},
}
# GIAS phases in the order a child meets them, for the phase filter's options.
# All-through spans the whole path, so it follows the stages. Lowercased, as
# PHASE_GROUPS is, so a change of case in the GIAS label keeps its place.
PHASE_ORDER: list[str] = [
"nursery",
"primary",
"middle deemed primary",
"middle deemed secondary",
"secondary",
"16 plus",
"all-through",
]
# School listing columns
SCHOOL_COLUMNS = [
"urn",
+22
View File
@@ -82,3 +82,25 @@ def test_grouped_phases_still_take_in_their_related_phases(client):
def test_an_unknown_phase_returns_nothing_rather_than_everything(client):
assert _urns(client, "kindergarten") == []
def test_filters_lists_phases_in_the_order_a_child_meets_them(client):
# Alphabetical put "16 plus" and "All-through" first and Nursery fifth.
# All-through spans the whole path, so it comes after the stages.
assert client.get("/api/filters").json()["phases"] == [
"Nursery",
"Primary",
"Middle deemed primary",
"Middle deemed secondary",
"Secondary",
"16 plus",
"All-through",
]
def test_an_unknown_phase_follows_the_known_ones():
from backend.app import order_phases
assert order_phases(["Secondary", "Zeta", "Alpha", "Nursery"]) == [
"Nursery", "Secondary", "Alpha", "Zeta",
]
+13
View File
@@ -286,6 +286,19 @@ test('the phase filter switches straight from secondary to primary', async ({ pa
await expect(phase).toHaveValue('primary');
});
test('the phase filter lists phases in the order a child meets them', async ({ page }) => {
// They were alphabetical, so "16 plus" and "All-through" came before Nursery.
const childPath = ['Nursery', 'Primary', 'Middle deemed primary',
'Middle deemed secondary', 'Secondary', '16 plus', 'All-through'];
await page.goto('/?search=school');
const phase = page.getByRole('combobox', { name: 'Phase' });
await expect(phase).toBeVisible({ timeout: 15_000 });
const offered = (await phase.locator('option').allTextContents())
.filter((o) => childPath.includes(o));
expect(offered, 'no phase on offer').toContain('Primary');
expect(offered).toEqual(childPath.filter((p) => offered.includes(p)));
});
test('school type and gender switch straight to another value', async ({ page }) => {
// Their options came from the result set, which the filter had already
// narrowed, so with one value chosen it was the only one on offer.