diff --git a/backend/app.py b/backend/app.py index 7f017df..1e7c758 100644 --- a/backend/app.py +++ b/backend/app.py @@ -604,6 +604,7 @@ 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"), + "status": latest.get("status"), "latitude": latest.get("latitude"), "longitude": latest.get("longitude"), "phase": latest.get("phase"), diff --git a/backend/data_loader.py b/backend/data_loader.py index 9e50c73..c753d1a 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -129,6 +129,7 @@ _MAIN_QUERY = text(""" s.gender, s.age_range, s.has_sixth_form, + s.status, s.admissions_policy, s.capacity, s.total_pupils AS gias_total_pupils, diff --git a/backend/schemas.py b/backend/schemas.py index a757469..8144116 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -544,6 +544,7 @@ SCHOOL_COLUMNS = [ "religious_denomination", "age_range", "has_sixth_form", + "status", "gender", "admissions_policy", "ofsted_grade", diff --git a/backend/tests/test_school_status.py b/backend/tests/test_school_status.py new file mode 100644 index 0000000..ff9787e --- /dev/null +++ b/backend/tests/test_school_status.py @@ -0,0 +1,70 @@ +"""Tests for GIAS establishment status exposure. + +"Open, but proposed to close" schools are now kept by the dims; the API must +surface `status` on list items and school_info so the UI can render the +proposed-to-close marker (listing tag) and notice strip (detail page). +""" + +import numpy as np +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +PROPOSED = "Open, but proposed to close" + + +def _schools_df() -> pd.DataFrame: + base = { + "local_authority": "Testshire", + "school_type": "Academy", + "phase": "Secondary", + "address": "1 Test Street", + "town": "Testtown", + "postcode": "TS1 1AA", + "religious_denomination": None, + "gender": "Mixed", + "age_range": "11-16", + "admissions_policy": None, + "has_sixth_form": False, + "ofsted_grade": np.nan, + "ofsted_date": None, + "ofsted_framework": None, + "latitude": 51.5, + "longitude": -0.1, + "year": 202425, + "total_pupils": 800, + "rwm_expected_pct": np.nan, + "attainment_8_score": 48.0, + } + return pd.DataFrame( + [ + {**base, "urn": 200001, "school_name": "Alpha Academy", + "status": "Open"}, + {**base, "urn": 200002, "school_name": "Sarson High School", + "status": PROPOSED}, + ] + ) + + +@pytest.fixture() +def client(monkeypatch): + from backend import app as app_module + + monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df) + monkeypatch.setattr(app_module, "load_school_data", _schools_df) + monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {}) + return TestClient(app_module.app, raise_server_exceptions=False) + + +def test_list_payload_includes_status(client): + resp = client.get("/api/schools") + assert resp.status_code == 200, resp.text + by_urn = {s["urn"]: s for s in resp.json()["schools"]} + assert by_urn[200001]["status"] == "Open" + assert by_urn[200002]["status"] == PROPOSED + + +def test_detail_payload_includes_status(client): + resp = client.get("/api/schools/200002") + assert resp.status_code == 200, resp.text + assert resp.json()["school_info"]["status"] == PROPOSED diff --git a/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx b/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx index affaecb..a868256 100644 --- a/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx +++ b/nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx @@ -44,3 +44,24 @@ describe('SecondarySchoolRow sixth-form tag', () => { expect(screen.queryByText('Sixth form')).not.toBeInTheDocument(); }); }); + +describe('SecondarySchoolRow proposed-to-close tag', () => { + it('shows the tag when GIAS status is "Open, but proposed to close"', () => { + render( + , + ); + expect(screen.getByText(/Proposed to close/)).toBeInTheDocument(); + }); + + it('hides the tag for a plain open school', () => { + render(); + expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument(); + }); + + it('hides the tag when status is missing', () => { + render(); + expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index 2a9c664..a1ebbbc 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -212,3 +212,14 @@ describe('computeYBounds', () => { expect(computeYBounds([], 'progress')).toEqual({}); }); }); + +describe('isProposedToClose', () => { + const { isProposedToClose } = require('@/lib/utils'); + + it('is true only for the exact GIAS proposed-to-close status', () => { + expect(isProposedToClose({ status: 'Open, but proposed to close' })).toBe(true); + expect(isProposedToClose({ status: 'Open' })).toBe(false); + expect(isProposedToClose({ status: null })).toBe(false); + expect(isProposedToClose({})).toBe(false); + }); +}); diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index e2ac1b8..b2b419a 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -1543,3 +1543,18 @@ .historyDisclosure[open] > .historyToggle::before { transform: rotate(90deg); } + +/* GIAS "Open, but proposed to close" notice strip */ +.closingStrip { + background: #fdf6e3; + border-left: 4px solid #e2c96f; + border-radius: 0 6px 6px 0; + padding: 0.55rem 0.9rem; + margin: 0.5rem 0; + font-size: 0.88rem; + color: #6e5a00; + max-width: 68ch; +} +.closingStrip strong { + color: #8a6200; +} diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 8625278..ab0dfe1 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -18,7 +18,7 @@ import type { SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; import { - formatPercentage, formatProgress, formatAcademicYear, + formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; @@ -313,6 +313,13 @@ export function SchoolDetailView({ {schoolInfo.gender}'s school )} + {isProposedToClose(schoolInfo) && ( +
+ ⚠ Proposed to close — this school is proposed for closure, + although a formal closure process has not necessarily started. Check with the + local authority before applying. +
+ )} {schoolInfo.address && (

{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} diff --git a/nextjs-app/components/SchoolRow.module.css b/nextjs-app/components/SchoolRow.module.css index 2bc0e47..20f03e8 100644 --- a/nextjs-app/components/SchoolRow.module.css +++ b/nextjs-app/components/SchoolRow.module.css @@ -254,3 +254,10 @@ justify-content: center; } } + +/* GIAS "Open, but proposed to close" marker */ +.attrClosing { + background: #fdf6e3; + color: #8a6200; + border: 1px solid #e2c96f; +} diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx index 71fd763..43dc205 100644 --- a/nextjs-app/components/SchoolRow.tsx +++ b/nextjs-app/components/SchoolRow.tsx @@ -9,7 +9,7 @@ */ import type { School } from '@/lib/types'; -import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange } from '@/lib/utils'; +import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils'; import styles from './SchoolRow.module.css'; interface SchoolRowProps { @@ -78,6 +78,9 @@ export function SchoolRow({ {school.age_range && {formatAgeRange(school.age_range)}} {showDenomination && {school.religious_denomination}} {showGender && {school.gender}} + {isProposedToClose(school) && ( + ⚠ Proposed to close + )} {/* Line 3: Key stats */} diff --git a/nextjs-app/components/SecondarySchoolDetailView.module.css b/nextjs-app/components/SecondarySchoolDetailView.module.css index 1c90337..6ac06bf 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.module.css +++ b/nextjs-app/components/SecondarySchoolDetailView.module.css @@ -1099,3 +1099,18 @@ padding: 0.75rem; } } + +/* GIAS "Open, but proposed to close" notice strip */ +.closingStrip { + background: #fdf6e3; + border-left: 4px solid #e2c96f; + border-radius: 0 6px 6px 0; + padding: 0.55rem 0.9rem; + margin: 0.5rem 0; + font-size: 0.88rem; + color: #6e5a00; + max-width: 68ch; +} +.closingStrip strong { + color: #8a6200; +} diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index 8210313..7246164 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -23,7 +23,7 @@ import type { SchoolAdmissions, SenDetail, Phonics, SchoolDeprivation, SchoolFinance, NationalAverages, } from '@/lib/types'; -import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange } from '@/lib/utils'; +import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; import { track, getNavigationSource } from '@/lib/analytics'; import styles from './SecondarySchoolDetailView.module.css'; @@ -237,6 +237,13 @@ export function SecondarySchoolDetailView({ )} + {isProposedToClose(schoolInfo) && ( +

+ ⚠ Proposed to close — this school is proposed for closure, + although a formal closure process has not necessarily started. Check with the + local authority before applying. +
+ )} {schoolInfo.address && (

{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} diff --git a/nextjs-app/components/SecondarySchoolRow.module.css b/nextjs-app/components/SecondarySchoolRow.module.css index 0949f11..ae64de6 100644 --- a/nextjs-app/components/SecondarySchoolRow.module.css +++ b/nextjs-app/components/SecondarySchoolRow.module.css @@ -266,3 +266,9 @@ justify-content: center; } } + +.closingTag { + background: #fdf6e3; + color: #8a6200; + border: 1px solid #e2c96f; +} diff --git a/nextjs-app/components/SecondarySchoolRow.tsx b/nextjs-app/components/SecondarySchoolRow.tsx index c32dab4..39c1256 100644 --- a/nextjs-app/components/SecondarySchoolRow.tsx +++ b/nextjs-app/components/SecondarySchoolRow.tsx @@ -11,7 +11,7 @@ 'use client'; import type { School } from '@/lib/types'; -import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange } from '@/lib/utils'; +import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils'; import styles from './SecondarySchoolRow.module.css'; function detectAdmissionsTag(school: School): string | null { @@ -97,6 +97,9 @@ export function SecondarySchoolRow({ {admissionsTag} )} + {isProposedToClose(school) && ( + ⚠ Proposed to close + )} {/* Line 3: KS4 stats */} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index dc1cd2a..c29512c 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; + status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close") // Address address1: string | null; diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 07b5d2e..fa1edf0 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -718,3 +718,18 @@ export function buildOfstedListBadge(school: { return { label: 'Not yet inspected', cssClass: 'ofstedPending' }; } + +// ============================================================================ +// Establishment status +// ============================================================================ + +export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close'; + +/** + * GIAS lists some operating schools as "Open, but proposed to close". + * They remain open (and may stay open if the proposal is withdrawn), but the + * UI marks them so families check with the local authority before applying. + */ +export function isProposedToClose(school: { status?: string | null }): boolean { + return school.status === PROPOSED_TO_CLOSE_STATUS; +} diff --git a/pipeline/transform/models/marts/_marts_schema.yml b/pipeline/transform/models/marts/_marts_schema.yml index a575a95..4b89d73 100644 --- a/pipeline/transform/models/marts/_marts_schema.yml +++ b/pipeline/transform/models/marts/_marts_schema.yml @@ -30,7 +30,7 @@ models: - name: status tests: - accepted_values: - values: ["Open"] + values: ["Open", "Open, but proposed to close"] - name: dim_location description: School location dimension with PostGIS geometry diff --git a/pipeline/transform/models/marts/dim_location.sql b/pipeline/transform/models/marts/dim_location.sql index be285a1..7c13c4b 100644 --- a/pipeline/transform/models/marts/dim_location.sql +++ b/pipeline/transform/models/marts/dim_location.sql @@ -31,4 +31,5 @@ select else null end as longitude from {{ ref('stg_gias_establishments') }} s -where s.status = 'Open' +-- Must match dim_school's status filter exactly (the API inner-joins the two). +where s.status in ('Open', 'Open, but proposed to close') diff --git a/pipeline/transform/models/marts/dim_school.sql b/pipeline/transform/models/marts/dim_school.sql index 0de54fa..fbcc8b1 100644 --- a/pipeline/transform/models/marts/dim_school.sql +++ b/pipeline/transform/models/marts/dim_school.sql @@ -91,4 +91,7 @@ from schools s {% if ofsted_relation is not none %} left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn {% endif %} -where s.status = 'Open' +-- "Open, but proposed to close" schools are still operating (pupils enrolled, +-- results published) — include them; they drop out automatically once GIAS +-- flips them to "Closed" (marts are fully rebuilt each run). +where s.status in ('Open', 'Open, but proposed to close')