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(
+
{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) && ( +
{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')