diff --git a/backend/app.py b/backend/app.py index 3e8d713..120fffc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -606,17 +606,21 @@ async def get_school_details(request: Request, urn: int): "religious_denomination": latest.get("religious_denomination", ""), "age_range": latest.get("age_range", ""), "has_sixth_form": latest.get("has_sixth_form"), + "nursery_provision": latest.get("nursery_provision"), "status": latest.get("status"), "latitude": latest.get("latitude"), "longitude": latest.get("longitude"), "phase": latest.get("phase"), # GIAS fields "website": latest.get("website"), + "telephone": latest.get("telephone"), "headteacher_name": latest.get("headteacher_name"), "capacity": latest.get("capacity"), "total_pupils": latest.get("gias_total_pupils"), "trust_name": latest.get("trust_name"), "gender": latest.get("gender"), + "county": latest.get("county"), + "parliamentary_constituency": latest.get("parliamentary_constituency"), }.items() } diff --git a/backend/data_loader.py b/backend/data_loader.py index 41cb0df..e3aec2f 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -169,6 +169,8 @@ _MAIN_QUERY = text(""" s.total_pupils AS gias_total_pupils, s.headteacher_name, s.website, + s.telephone, + s.nursery_provision, foi.ofsted_grade, foi.ofsted_date, foi.ofsted_framework, @@ -178,6 +180,8 @@ _MAIN_QUERY = text(""" l.address_line1 AS address1, l.address_line2 AS address2, l.town, + l.county, + l.parliamentary_constituency, l.postcode, l.latitude, l.longitude, @@ -301,6 +305,32 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS: _MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new) _MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES) +# Fallback used when marts predate the optional GIAS/location detail columns +# (telephone, nursery provision, county, parliamentary constituency) — i.e. the +# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column +# present as NULL so the whole data load degrades gracefully instead of failing. +_EXTRA_OPTIONAL_COLUMNS = ( + "telephone", + "nursery_provision", + "county", + "parliamentary_constituency", +) +_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY) +for _col, _tbl in ( + ("telephone", "s"), + ("nursery_provision", "s"), + ("county", "l"), + ("parliamentary_constituency", "l"), +): + _src = f"{_tbl}.{_col}," + assert _src in _MAIN_QUERY_NO_EXTRA_COLS, ( + f"expected {_src!r} to be present in _MAIN_QUERY before replacement" + ) + _MAIN_QUERY_NO_EXTRA_COLS = _MAIN_QUERY_NO_EXTRA_COLS.replace( + _src, f"NULL AS {_col}," + ) +_MAIN_QUERY_NO_EXTRA_COLS = text(_MAIN_QUERY_NO_EXTRA_COLS) + _GIAS_CODE_COLUMN_NAMES = ( "phase_code", "school_type_code", @@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = ( "admissions_policy_code", ) -_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist') +# Strip any single table alias prefix (s., l., p., foi., …) — Postgres reports +# an undefined *qualified* column unquoted as "column l.county does not exist", +# so matching only the s. alias would miss dim_location columns (county, +# parliamentary_constituency) and defeat the graceful-degradation fallback. +_MISSING_COLUMN_RE = re.compile(r'column "?(?:\w+\.)?(\w+)"? does not exist') def _missing_column_name(exc: Exception) -> Optional[str]: @@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame: except Exception as exc2: print(f"Warning: Could not load school data from marts: {exc2}") return pd.DataFrame() + elif missing in _EXTRA_OPTIONAL_COLUMNS: + logging.getLogger(__name__).warning( + "marts.dim_school/dim_location is missing %s (pipeline hasn't " + "rebuilt the mart yet on this DB) — retrying without the " + "optional detail columns: %s", + missing, exc, + ) + try: + df = pd.read_sql(_MAIN_QUERY_NO_EXTRA_COLS, engine) + except Exception as exc2: + print(f"Warning: Could not load school data from marts: {exc2}") + return pd.DataFrame() else: print(f"Warning: Could not load school data from marts: {exc}") return pd.DataFrame() diff --git a/backend/tests/test_gias_translation.py b/backend/tests/test_gias_translation.py index 414b8e4..9b57a10 100644 --- a/backend/tests/test_gias_translation.py +++ b/backend/tests/test_gias_translation.py @@ -73,6 +73,24 @@ def test_missing_column_name_table_prefixed(): ) +def test_missing_column_name_location_alias_prefixed(): + # dim_location columns are selected via the `l.` alias; Postgres reports a + # missing qualified column unquoted (e.g. "column l.county does not exist"). + # The matcher must strip any alias, not just `s.`, or the county / + # parliamentary_constituency fallback never triggers and the whole data + # load degrades to an empty DataFrame (zero schools) instead of NULLs. + assert ( + _missing_column_name(_fake_exc("column l.county does not exist")) + == "county" + ) + assert ( + _missing_column_name( + _fake_exc("column l.parliamentary_constituency does not exist") + ) + == "parliamentary_constituency" + ) + + def test_missing_column_name_no_match_returns_none(): assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 4ac1f72..2ed3e54 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -121,6 +121,33 @@ test('school detail page renders name and performance data', async ({ page }) => await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); }); +test('school detail page shows GIAS identity/contact details and drops the unwired Phonics section', async ({ page }) => { + const [urn] = await twoPrimaryUrns(page); + const res = await page.request.get(`/api/schools/${urn}`); + expect(res.ok()).toBeTruthy(); + const info = (await res.json()).school_info; + + await page.goto(`/school/${urn}`); + await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 }); + + // Phonics, SEN-type breakdown and average class size were never populated by + // the backend — the sections have been removed, so the Phonics section (its + // own #phonics anchor) must no longer exist. + await expect(page.locator('#phonics')).toHaveCount(0); + + // Newly surfaced GIAS/location fields render when the record carries them. + const ageMatch = String(info.age_range ?? '').match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/); + if (ageMatch) { + await expect(page.getByText(`Ages ${ageMatch[1]}–${ageMatch[2]}`).first()).toBeVisible(); + } + if (info.telephone) { + await expect(page.locator('a[href^="tel:"]').first()).toBeVisible(); + } + if (info.parliamentary_constituency) { + await expect(page.getByText('Constituency:').first()).toBeVisible(); + } +}); + test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => { // Detail views detected report cards via `framework`, which the API never // sets to "ReportCard" — so report-card schools rendered as legacy ratings diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx index 232ded8..176c692 100644 --- a/nextjs-app/app/school/[slug]/page.tsx +++ b/nextjs-app/app/school/[slug]/page.tsx @@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { notFound(); } - const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data; + const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data; // Redirect bare URN to canonical slug URL const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); @@ -191,8 +191,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) { ofsted={ofsted ?? null} census={census ?? null} admissions={admissions ?? null} - senDetail={sen_detail ?? null} - phonics={phonics ?? null} deprivation={deprivation ?? null} finance={finance ?? null} /> @@ -205,8 +203,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) { census={census ?? null} admissions={admissions ?? null} admissionsHistory={admissions_history ?? []} - senDetail={sen_detail ?? null} - phonics={phonics ?? null} deprivation={deprivation ?? null} finance={finance ?? null} /> diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 1148e52..adca63c 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -14,11 +14,11 @@ import { MetricTooltip } from './MetricTooltip'; import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, - SchoolAdmissions, SenDetail, Phonics, + SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { - formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, + formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; import { SpecialSchoolNote } from './SpecialSchoolNote'; @@ -68,15 +68,13 @@ interface SchoolDetailViewProps { census: SchoolCensus | null; admissions: SchoolAdmissions | null; admissionsHistory: SchoolAdmissions[]; - senDetail: SenDetail | null; - phonics: Phonics | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; } export function SchoolDetailView({ schoolInfo, yearlyData, absenceData, - ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance, + ofsted, census, admissions, admissionsHistory, deprivation, finance, }: SchoolDetailViewProps) { const router = useRouter(); const { addSchool, removeSchool, isSelected } = useComparison(); @@ -161,14 +159,12 @@ export function SchoolDetailView({ // Phase detection. All-through schools cover BOTH key stages, so they are // neither "pure primary" nor "pure secondary": isSecondary stays true (they - // have KS4 data) but isAllThrough gates the primary-only content (phonics, + // have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs, // KS2 trend) back on and switches phase-specific copy to an all-ages framing. const phase = schoolInfo.phase ?? ''; const isAllThrough = phase.toLowerCase() === 'all-through'; const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough; const isPrimary = !isSecondary; - // Primary-stage content shows for pure-primary AND all-through schools. - const showPrimaryContent = isPrimary || isAllThrough; // National averages (fetched dynamically so they stay current) const [nationalAvg, setNationalAvg] = useState(null); @@ -220,11 +216,9 @@ export function SchoolDetailView({ const hasInclusionData = (latestResults?.disadvantaged_pct != null) || (latestResults?.eal_pct != null) || (latestResults?.sen_support_pct != null) - || senDetail != null || hasGenderSplit; - const hasSchoolLife = absenceData != null || census?.class_size_avg != null; - const hasPhonics = phonics != null && phonics.year1_phonics_pct != null; + const hasSchoolLife = absenceData != null; const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; const hasFinance = finance != null && finance.per_pupil_spend != null; const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; @@ -265,7 +259,6 @@ export function SchoolDetailView({ if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' }); - if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); @@ -358,6 +351,22 @@ export function SchoolDetailView({ {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender}'s school )} + {schoolInfo.age_range && ( + {formatAgeRange(schoolInfo.age_range)} + )} + {schoolInfo.religious_denomination && ( + + {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) + ? 'None' + : schoolInfo.religious_denomination} + + )} + {schoolInfo.nursery_provision && ( + Nursery + )} + {schoolInfo.has_sixth_form && ( + Sixth form + )} {isProposedToClose(schoolInfo) && (
@@ -416,6 +425,24 @@ export function SchoolDetailView({ Part of {schoolInfo.trust_name} )} + {schoolInfo.telephone && ( + + Phone:{' '} + + {schoolInfo.telephone} + + + )} + {schoolInfo.county && ( + + County: {schoolInfo.county} + + )} + {schoolInfo.parliamentary_constituency && ( + + Constituency: {schoolInfo.parliamentary_constituency} + + )}
@@ -1084,29 +1111,6 @@ export function SchoolDetailView({ ); })()}
- {senDetail && ( - <> -

Types of additional needs supported

-

- What proportion of pupils with additional needs have each type of support need. -

-
- {[ - { label: 'Speech & Language', pct: senDetail.primary_need_speech_pct }, - { label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct }, - { label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct }, - { label: 'Specific Learning (e.g. Dyslexia)', pct: senDetail.primary_need_spld_pct }, - { label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct }, - { label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct }, - ].filter(n => n.pct != null).map(({ label, pct }) => ( -
-
{label}
-
{pct}%
-
- ))} -
- - )} )} @@ -1231,41 +1235,11 @@ export function SchoolDetailView({ )} )} - {/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */} - {hasPhonics && showPrimaryContent && phonics && ( -
-

Year 1 Phonics ({formatAcademicYear(phonics.year)})

-

- Phonics is a key early reading skill. Children are tested at the end of Year 1. -

-
-
-
Passed the phonics check
-
{formatPercentage(phonics.year1_phonics_pct)}
-
Phonics is a key early reading skill tested at end of Year 1
-
- {phonics.year2_phonics_pct != null && ( -
-
Year 2 pupils who retook and passed
-
{formatPercentage(phonics.year2_phonics_pct)}
-
- )} -
-
- )} - {/* School Life */} {hasSchoolLife && (

School Life

- {census?.class_size_avg != null && ( -
-
Average class size
-
{census.class_size_avg.toFixed(1)}
-
Average number of pupils per class
-
- )} {absenceData?.overall_absence_rate != null && (
diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index 8504a49..1304a36 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -20,7 +20,7 @@ const PerformanceChart = dynamic( import type { School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus, - SchoolAdmissions, SenDetail, Phonics, + SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils'; @@ -68,15 +68,13 @@ interface SecondarySchoolDetailViewProps { ofsted: OfstedInspection | null; census: SchoolCensus | null; admissions: SchoolAdmissions | null; - senDetail: SenDetail | null; - phonics: Phonics | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; } export function SecondarySchoolDetailView({ schoolInfo, yearlyData, - ofsted, census, admissions, senDetail, deprivation, finance, absenceData, + ofsted, census, admissions, deprivation, finance, absenceData, }: SecondarySchoolDetailViewProps) { const router = useRouter(); // Hero map — the "View on map" link opens its fullscreen view. @@ -248,6 +246,16 @@ export function SecondarySchoolDetailView({ {schoolInfo.age_range && ( {formatAgeRange(schoolInfo.age_range)} )} + {schoolInfo.religious_denomination && ( + + {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) + ? 'None' + : schoolInfo.religious_denomination} + + )} + {schoolInfo.nursery_provision && ( + Nursery + )} {hasSixthForm && ( Sixth form )} @@ -310,6 +318,24 @@ export function SecondarySchoolDetailView({ Part of {schoolInfo.trust_name} )} + {schoolInfo.telephone && ( + + Phone:{' '} + + {schoolInfo.telephone} + + + )} + {schoolInfo.county && ( + + County: {schoolInfo.county} + + )} + {schoolInfo.parliamentary_constituency && ( + + Constituency: {schoolInfo.parliamentary_constituency} + + )}
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 21d751a..7ccf902 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -18,6 +18,7 @@ export interface School { religious_denomination: string | null; age_range: string | null; has_sixth_form?: boolean | null; + nursery_provision?: boolean | null; status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close") // Address @@ -60,11 +61,14 @@ export interface School { // GIAS enrichment fields website?: string | null; + telephone?: string | null; headteacher_name?: string | null; capacity?: number | null; trust_name?: string | null; gender?: string | null; admissions_policy?: string | null; + county?: string | null; + parliamentary_constituency?: string | null; // Ofsted (for list view — summary only) ofsted_grade?: 1 | 2 | 3 | 4 | null; @@ -130,7 +134,6 @@ export interface SchoolCensus { male_pupils: number | null; fsm_pct: number | null; eal_pct: number | null; - class_size_avg?: number | null; ethnicity_white_pct?: number | null; ethnicity_asian_pct?: number | null; ethnicity_black_pct?: number | null; @@ -159,22 +162,6 @@ export interface SchoolAdmissions { cross_la_offers?: number | null; } -export interface SenDetail { - year: number; - primary_need_speech_pct: number | null; - primary_need_autism_pct: number | null; - primary_need_mld_pct: number | null; - primary_need_spld_pct: number | null; - primary_need_semh_pct: number | null; - primary_need_physical_pct: number | null; - primary_need_other_pct: number | null; -} - -export interface Phonics { - year: number; - year1_phonics_pct: number | null; - year2_phonics_pct: number | null; -} export interface SchoolDeprivation { lsoa_code: string | null; @@ -343,8 +330,6 @@ export interface SchoolDetailsResponse { admissions: SchoolAdmissions | null; /** All available admissions years, oldest first. Drives the multi-year trend view. */ admissions_history: SchoolAdmissions[]; - sen_detail: SenDetail | null; - phonics: Phonics | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; }