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
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:
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -544,6 +544,7 @@ SCHOOL_COLUMNS = [
|
||||
"religious_denomination",
|
||||
"age_range",
|
||||
"has_sixth_form",
|
||||
"status",
|
||||
"gender",
|
||||
"admissions_policy",
|
||||
"ofsted_grade",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||
)}
|
||||
</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 && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
|
||||
@@ -254,3 +254,10 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* GIAS "Open, but proposed to close" marker */
|
||||
.attrClosing {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
}
|
||||
|
||||
@@ -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 && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
|
||||
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
|
||||
{showGender && <span className={styles.attr}>{school.gender}</span>}
|
||||
{isProposedToClose(school) && (
|
||||
<span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 3: Key stats */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
)}
|
||||
</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 && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
|
||||
@@ -266,3 +266,9 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.closingTag {
|
||||
background: #fdf6e3;
|
||||
color: #8a6200;
|
||||
border: 1px solid #e2c96f;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
)}
|
||||
{isProposedToClose(school) && (
|
||||
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 3: KS4 stats */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user