Merge pull request 'feat: include and mark 'Open, but proposed to close' schools' (#22) from feat/proposed-to-close-schools into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 20s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 55s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 41s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 10s

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
2026-07-08 21:23:23 +00:00
19 changed files with 195 additions and 7 deletions
+1
View File
@@ -604,6 +604,7 @@ async def get_school_details(request: Request, urn: int):
"religious_denomination": latest.get("religious_denomination", ""), "religious_denomination": latest.get("religious_denomination", ""),
"age_range": latest.get("age_range", ""), "age_range": latest.get("age_range", ""),
"has_sixth_form": latest.get("has_sixth_form"), "has_sixth_form": latest.get("has_sixth_form"),
"status": latest.get("status"),
"latitude": latest.get("latitude"), "latitude": latest.get("latitude"),
"longitude": latest.get("longitude"), "longitude": latest.get("longitude"),
"phase": latest.get("phase"), "phase": latest.get("phase"),
+1
View File
@@ -129,6 +129,7 @@ _MAIN_QUERY = text("""
s.gender, s.gender,
s.age_range, s.age_range,
s.has_sixth_form, s.has_sixth_form,
s.status,
s.admissions_policy, s.admissions_policy,
s.capacity, s.capacity,
s.total_pupils AS gias_total_pupils, s.total_pupils AS gias_total_pupils,
+1
View File
@@ -544,6 +544,7 @@ SCHOOL_COLUMNS = [
"religious_denomination", "religious_denomination",
"age_range", "age_range",
"has_sixth_form", "has_sixth_form",
"status",
"gender", "gender",
"admissions_policy", "admissions_policy",
"ofsted_grade", "ofsted_grade",
+70
View File
@@ -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
@@ -44,3 +44,24 @@ describe('SecondarySchoolRow sixth-form tag', () => {
expect(screen.queryByText('Sixth form')).not.toBeInTheDocument(); 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(
<SecondarySchoolRow
school={{ ...base, status: 'Open, but proposed to close' }}
/>,
);
expect(screen.getByText(/Proposed to close/)).toBeInTheDocument();
});
it('hides the tag for a plain open school', () => {
render(<SecondarySchoolRow school={{ ...base, status: 'Open' }} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
it('hides the tag when status is missing', () => {
render(<SecondarySchoolRow school={base} />);
expect(screen.queryByText(/Proposed to close/)).not.toBeInTheDocument();
});
});
+11
View File
@@ -212,3 +212,14 @@ describe('computeYBounds', () => {
expect(computeYBounds([], 'progress')).toEqual({}); 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);
});
});
@@ -1543,3 +1543,18 @@
.historyDisclosure[open] > .historyToggle::before { .historyDisclosure[open] > .historyToggle::before {
transform: rotate(90deg); 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;
}
+8 -1
View File
@@ -18,7 +18,7 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { import {
formatPercentage, formatProgress, formatAcademicYear, formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
} from '@/lib/utils'; } from '@/lib/utils';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
@@ -313,6 +313,13 @@ export function SchoolDetailView({
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span> <span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)} )}
</div> </div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
although a formal closure process has not necessarily started. Check with the
local authority before applying.
</div>
)}
{schoolInfo.address && ( {schoolInfo.address && (
<p className={styles.address}> <p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -254,3 +254,10 @@
justify-content: center; justify-content: center;
} }
} }
/* GIAS "Open, but proposed to close" marker */
.attrClosing {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+4 -1
View File
@@ -9,7 +9,7 @@
*/ */
import type { School } from '@/lib/types'; 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'; import styles from './SchoolRow.module.css';
interface SchoolRowProps { interface SchoolRowProps {
@@ -78,6 +78,9 @@ export function SchoolRow({
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>} {school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>} {showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>} {showGender && <span className={styles.attr}>{school.gender}</span>}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}> Proposed to close</span>
)}
</div> </div>
{/* Line 3: Key stats */} {/* Line 3: Key stats */}
@@ -1099,3 +1099,18 @@
padding: 0.75rem; 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;
}
@@ -23,7 +23,7 @@ import type {
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } 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 { DeltaChip } from './DeltaChip';
import { track, getNavigationSource } from '@/lib/analytics'; import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css'; import styles from './SecondarySchoolDetailView.module.css';
@@ -237,6 +237,13 @@ export function SecondarySchoolDetailView({
</span> </span>
)} )}
</div> </div>
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong> Proposed to close</strong> this school is proposed for closure,
although a formal closure process has not necessarily started. Check with the
local authority before applying.
</div>
)}
{schoolInfo.address && ( {schoolInfo.address && (
<p className={styles.address}> <p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} {schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
@@ -266,3 +266,9 @@
justify-content: center; justify-content: center;
} }
} }
.closingTag {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
}
+4 -1
View File
@@ -11,7 +11,7 @@
'use client'; 'use client';
import type { School } from '@/lib/types'; 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'; import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null { function detectAdmissionsTag(school: School): string | null {
@@ -97,6 +97,9 @@ export function SecondarySchoolRow({
{admissionsTag} {admissionsTag}
</span> </span>
)} )}
{isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}> Proposed to close</span>
)}
</div> </div>
{/* Line 3: KS4 stats */} {/* Line 3: KS4 stats */}
+1
View File
@@ -18,6 +18,7 @@ export interface School {
religious_denomination: string | null; religious_denomination: string | null;
age_range: string | null; age_range: string | null;
has_sixth_form?: boolean | null; has_sixth_form?: boolean | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address // Address
address1: string | null; address1: string | null;
+15
View File
@@ -718,3 +718,18 @@ export function buildOfstedListBadge(school: {
return { label: 'Not yet inspected', cssClass: 'ofstedPending' }; 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;
}
@@ -30,7 +30,7 @@ models:
- name: status - name: status
tests: tests:
- accepted_values: - accepted_values:
values: ["Open"] values: ["Open", "Open, but proposed to close"]
- name: dim_location - name: dim_location
description: School location dimension with PostGIS geometry description: School location dimension with PostGIS geometry
@@ -31,4 +31,5 @@ select
else null else null
end as longitude end as longitude
from {{ ref('stg_gias_establishments') }} s 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')
@@ -91,4 +91,7 @@ from schools s
{% if ofsted_relation is not none %} {% if ofsted_relation is not none %}
left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn left join {{ ref('int_ofsted_latest') }} o on s.urn = o.urn
{% endif %} {% 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')