From 1c49a135c4488f8b092976837623b85df853c673 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 25 Mar 2026 13:03:04 +0000 Subject: [PATCH] feat(ofsted): add Report Card system support alongside legacy OEIF grades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ofsted replaced single overall grades with Report Cards from Nov 2025. Both systems are retained during the transition period. - DB: new framework + 9 RC columns on ofsted_inspections (schema v4) - Integrator: auto-detect OEIF vs Report Card from CSV column headers; parse 5-level RC grades and safeguarding met/not-met - API: expose all new fields in the ofsted response dict - Frontend: branch on framework='ReportCard' to show safeguarding badge + 8-category grid; fall back to legacy OEIF layout otherwise; always show inspection date in both layouts - CSS: rcGrade1–5 and safeguardingMet/NotMet classes Co-Authored-By: Claude Sonnet 4.6 --- backend/data_loader.py | 16 +- backend/models.py | 18 +- backend/version.py | 3 +- integrator/scripts/sources/ofsted.py | 161 ++++++++++++++++-- nextjs-app/components/Footer.tsx | 11 +- .../components/SchoolDetailView.module.css | 38 +++++ nextjs-app/components/SchoolDetailView.tsx | 128 ++++++++++---- nextjs-app/docker-compose.yml | 2 - nextjs-app/lib/types.ts | 17 +- 9 files changed, 325 insertions(+), 69 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 61a0fca..2a372ed 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -575,6 +575,10 @@ def get_supplementary_data(db: Session, urn: int) -> dict: # Ofsted inspection o = safe_query(OfstedInspection, "urn") result["ofsted"] = { + "framework": o.framework, + "inspection_date": o.inspection_date.isoformat() if o.inspection_date else None, + "inspection_type": o.inspection_type, + # OEIF fields (old framework) "overall_effectiveness": o.overall_effectiveness, "quality_of_education": o.quality_of_education, "behaviour_attitudes": o.behaviour_attitudes, @@ -582,8 +586,16 @@ def get_supplementary_data(db: Session, urn: int) -> dict: "leadership_management": o.leadership_management, "early_years_provision": o.early_years_provision, "previous_overall": o.previous_overall, - "inspection_date": o.inspection_date.isoformat() if o.inspection_date else None, - "inspection_type": o.inspection_type, + # Report Card fields (new framework, from Nov 2025) + "rc_safeguarding_met": o.rc_safeguarding_met, + "rc_inclusion": o.rc_inclusion, + "rc_curriculum_teaching": o.rc_curriculum_teaching, + "rc_achievement": o.rc_achievement, + "rc_attendance_behaviour": o.rc_attendance_behaviour, + "rc_personal_development": o.rc_personal_development, + "rc_leadership_governance": o.rc_leadership_governance, + "rc_early_years": o.rc_early_years, + "rc_sixth_form": o.rc_sixth_form, } if o else None # Parent View diff --git a/backend/models.py b/backend/models.py index 4bfd094..1e351de 100644 --- a/backend/models.py +++ b/backend/models.py @@ -171,6 +171,10 @@ class OfstedInspection(Base): inspection_date = Column(Date) publication_date = Column(Date) inspection_type = Column(String(100)) # Section 5 / Section 8 etc. + # Which inspection framework was used: 'OEIF' or 'ReportCard' + framework = Column(String(20)) + + # --- OEIF grades (old framework, pre-Nov 2025) --- # 1=Outstanding 2=Good 3=Requires improvement 4=Inadequate overall_effectiveness = Column(Integer) quality_of_education = Column(Integer) @@ -180,8 +184,20 @@ class OfstedInspection(Base): early_years_provision = Column(Integer) # nullable — not all schools previous_overall = Column(Integer) # for trend display + # --- Report Card grades (new framework, from Nov 2025) --- + # 1=Exceptional 2=Strong 3=Expected standard 4=Needs attention 5=Urgent improvement + rc_safeguarding_met = Column(Boolean) # True=Met, False=Not met + rc_inclusion = Column(Integer) + rc_curriculum_teaching = Column(Integer) + rc_achievement = Column(Integer) + rc_attendance_behaviour = Column(Integer) + rc_personal_development = Column(Integer) + rc_leadership_governance = Column(Integer) + rc_early_years = Column(Integer) # nullable — not all schools + rc_sixth_form = Column(Integer) # nullable — secondary only + def __repr__(self): - return f"" + return f"" class OfstedParentView(Base): diff --git a/backend/version.py b/backend/version.py index c5a21f8..12b4ff5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -13,11 +13,12 @@ WHEN TO BUMP: """ # Current schema version - increment when models change -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 # Changelog for documentation SCHEMA_CHANGELOG = { 1: "Initial schema with School and SchoolResult tables", 2: "Added pupil absence fields (reading, maths, gps, writing, science)", 3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools", + 4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)", } diff --git a/integrator/scripts/sources/ofsted.py b/integrator/scripts/sources/ofsted.py index 10b0531..561b9d4 100644 --- a/integrator/scripts/sources/ofsted.py +++ b/integrator/scripts/sources/ofsted.py @@ -81,6 +81,65 @@ GRADE_MAP = { "Inadequate": 4, "4": 4, 4: 4, } +# Report Card grade text → integer (1=Exceptional … 5=Urgent improvement) +RC_GRADE_MAP = { + "exceptional": 1, + "strong standard": 2, + "strong": 2, + "expected standard": 3, + "expected": 3, + "needs attention": 4, + "urgent improvement": 5, +} + +# Column name priority for Report Card fields (best-guess names; Ofsted may vary) +RC_COLUMN_PRIORITY = { + "rc_safeguarding": [ + "Safeguarding", + "safeguarding", + "Safeguarding standards", + ], + "rc_inclusion": [ + "Inclusion", + "inclusion", + ], + "rc_curriculum_teaching": [ + "Curriculum and teaching", + "curriculum_and_teaching", + "Curriculum & teaching", + ], + "rc_achievement": [ + "Achievement", + "achievement", + ], + "rc_attendance_behaviour": [ + "Attendance and behaviour", + "attendance_and_behaviour", + "Attendance & behaviour", + ], + "rc_personal_development": [ + "Personal development and well-being", + "Personal development and wellbeing", + "personal_development_and_wellbeing", + "Personal development & well-being", + ], + "rc_leadership_governance": [ + "Leadership and governance", + "leadership_and_governance", + "Leadership & governance", + ], + "rc_early_years": [ + "Early years", + "early_years", + "Early years provision", + ], + "rc_sixth_form": [ + "Sixth form", + "sixth_form", + "Sixth form in schools", + ], +} + DEST_DIR = SUPPLEMENTARY_DIR / "ofsted" @@ -137,6 +196,26 @@ def _parse_grade(val) -> int | None: return GRADE_MAP.get(key) +def _parse_rc_grade(val) -> int | None: + """Parse a Report Card grade text to integer 1–5.""" + if pd.isna(val): + return None + key = str(val).strip().lower() + return RC_GRADE_MAP.get(key) + + +def _parse_safeguarding(val) -> bool | None: + """Parse safeguarding 'Met'/'Not met' to boolean.""" + if pd.isna(val): + return None + s = str(val).strip().lower() + if s == "met": + return True + if s in ("not met", "not_met"): + return False + return None + + def _parse_date(val) -> date | None: if pd.isna(val): return None @@ -148,6 +227,19 @@ def _parse_date(val) -> date | None: return None +def _detect_framework(df: pd.DataFrame) -> str: + """Return 'ReportCard' if new-format columns are present, else 'OEIF'.""" + rc_indicators = [ + "inclusion", "curriculum and teaching", "achievement", + "attendance and behaviour", "safeguarding standards", "safeguarding", + ] + cols_lower = {c.lower() for c in df.columns} + for indicator in rc_indicators: + if any(indicator in c for c in cols_lower): + return "ReportCard" + return "OEIF" + + def load(path: Path | None = None, data_dir: Path | None = None) -> dict: if path is None: dest = (data_dir / "supplementary" / "ofsted") if data_dir else DEST_DIR @@ -186,7 +278,11 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict: hdr = _find_header_row(path) df = pd.read_csv(path, encoding="latin-1", low_memory=False, header=hdr) - # Normalise column names: for each target field pick the first source column present + # Detect which framework the CSV represents BEFORE any renaming + framework = _detect_framework(df) + print(f" Ofsted: detected framework '{framework}'") + + # Normalise OEIF column names: for each target field pick the first source column present available = set(df.columns) for target, sources in COLUMN_PRIORITY.items(): for src in sources: @@ -194,6 +290,14 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict: df.rename(columns={src: target}, inplace=True) break + # Normalise Report Card column names (if present) + available = set(df.columns) + for target, sources in RC_COLUMN_PRIORITY.items(): + for src in sources: + if src in available: + df.rename(columns={src: target}, inplace=True) + break + if "urn" not in df.columns: raise ValueError(f"URN column not found. Available: {list(df.columns)[:20]}") @@ -210,14 +314,18 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict: df["_date_parsed"] = df["inspection_date"].apply(_parse_date) df = df.sort_values("_date_parsed", ascending=False).groupby("urn").first().reset_index() + from sqlalchemy import text + for _, row in df.iterrows(): urn = int(row["urn"]) record = { "urn": urn, + "framework": framework, "inspection_date": _parse_date(row.get("inspection_date")), "publication_date": _parse_date(row.get("publication_date")), "inspection_type": str(row.get("inspection_type", "")).strip() or None, + # OEIF fields "overall_effectiveness": _parse_grade(row.get("overall_effectiveness")), "quality_of_education": _parse_grade(row.get("quality_of_education")), "behaviour_attitudes": _parse_grade(row.get("behaviour_attitudes")), @@ -225,32 +333,57 @@ def load(path: Path | None = None, data_dir: Path | None = None) -> dict: "leadership_management": _parse_grade(row.get("leadership_management")), "early_years_provision": _parse_grade(row.get("early_years_provision")), "previous_overall": None, + # Report Card fields + "rc_safeguarding_met": _parse_safeguarding(row.get("rc_safeguarding")), + "rc_inclusion": _parse_rc_grade(row.get("rc_inclusion")), + "rc_curriculum_teaching": _parse_rc_grade(row.get("rc_curriculum_teaching")), + "rc_achievement": _parse_rc_grade(row.get("rc_achievement")), + "rc_attendance_behaviour": _parse_rc_grade(row.get("rc_attendance_behaviour")), + "rc_personal_development": _parse_rc_grade(row.get("rc_personal_development")), + "rc_leadership_governance": _parse_rc_grade(row.get("rc_leadership_governance")), + "rc_early_years": _parse_rc_grade(row.get("rc_early_years")), + "rc_sixth_form": _parse_rc_grade(row.get("rc_sixth_form")), } - from sqlalchemy import text session.execute( text(""" INSERT INTO ofsted_inspections - (urn, inspection_date, publication_date, inspection_type, + (urn, framework, inspection_date, publication_date, inspection_type, overall_effectiveness, quality_of_education, behaviour_attitudes, personal_development, leadership_management, early_years_provision, - previous_overall) + previous_overall, + rc_safeguarding_met, rc_inclusion, rc_curriculum_teaching, + rc_achievement, rc_attendance_behaviour, rc_personal_development, + rc_leadership_governance, rc_early_years, rc_sixth_form) VALUES - (:urn, :inspection_date, :publication_date, :inspection_type, + (:urn, :framework, :inspection_date, :publication_date, :inspection_type, :overall_effectiveness, :quality_of_education, :behaviour_attitudes, :personal_development, :leadership_management, :early_years_provision, - :previous_overall) + :previous_overall, + :rc_safeguarding_met, :rc_inclusion, :rc_curriculum_teaching, + :rc_achievement, :rc_attendance_behaviour, :rc_personal_development, + :rc_leadership_governance, :rc_early_years, :rc_sixth_form) ON CONFLICT (urn) DO UPDATE SET - previous_overall = ofsted_inspections.overall_effectiveness, - inspection_date = EXCLUDED.inspection_date, - publication_date = EXCLUDED.publication_date, - inspection_type = EXCLUDED.inspection_type, + previous_overall = ofsted_inspections.overall_effectiveness, + framework = EXCLUDED.framework, + inspection_date = EXCLUDED.inspection_date, + publication_date = EXCLUDED.publication_date, + inspection_type = EXCLUDED.inspection_type, overall_effectiveness = EXCLUDED.overall_effectiveness, - quality_of_education = EXCLUDED.quality_of_education, - behaviour_attitudes = EXCLUDED.behaviour_attitudes, - personal_development = EXCLUDED.personal_development, + quality_of_education = EXCLUDED.quality_of_education, + behaviour_attitudes = EXCLUDED.behaviour_attitudes, + personal_development = EXCLUDED.personal_development, leadership_management = EXCLUDED.leadership_management, - early_years_provision = EXCLUDED.early_years_provision + early_years_provision = EXCLUDED.early_years_provision, + rc_safeguarding_met = EXCLUDED.rc_safeguarding_met, + rc_inclusion = EXCLUDED.rc_inclusion, + rc_curriculum_teaching = EXCLUDED.rc_curriculum_teaching, + rc_achievement = EXCLUDED.rc_achievement, + rc_attendance_behaviour = EXCLUDED.rc_attendance_behaviour, + rc_personal_development = EXCLUDED.rc_personal_development, + rc_leadership_governance = EXCLUDED.rc_leadership_governance, + rc_early_years = EXCLUDED.rc_early_years, + rc_sixth_form = EXCLUDED.rc_sixth_form """), record, ) diff --git a/nextjs-app/components/Footer.tsx b/nextjs-app/components/Footer.tsx index 19b48ad..f0713b3 100644 --- a/nextjs-app/components/Footer.tsx +++ b/nextjs-app/components/Footer.tsx @@ -15,8 +15,7 @@ export function Footer() {

SchoolCompare

- Compare primary school KS2 performance across England. Data sourced from UK Government - Compare School Performance. + Compare primary schools across England.

@@ -24,14 +23,6 @@ export function Footer() {

About

diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index cf9bab8..086533c 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -475,6 +475,44 @@ .ofstedGrade3 { background: rgba(201, 162, 39, 0.15); color: #b8920e; } .ofstedGrade4 { background: rgba(224, 114, 86, 0.15); color: var(--accent-coral, #e07256); } +/* Report Card grade colours (5-level scale, lower = better) */ +.rcGrade1 { background: rgba(45, 125, 125, 0.12); color: var(--accent-teal, #2d7d7d); } /* Exceptional */ +.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; } /* Strong */ +.rcGrade3 { background: rgba(201, 162, 39, 0.15); color: #b8920e; } /* Expected standard */ +.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; } /* Needs attention */ +.rcGrade5 { background: rgba(224, 114, 86, 0.15); color: var(--accent-coral, #e07256); } /* Urgent improvement */ + +/* Safeguarding badge */ +.safeguardingRow { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} +.safeguardingLabel { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #1a1612); +} +.safeguardingMet { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 600; + background: rgba(45, 125, 125, 0.12); + color: var(--accent-teal, #2d7d7d); +} +.safeguardingNotMet { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 700; + background: rgba(224, 114, 86, 0.15); + color: var(--accent-coral, #e07256); +} + .ofstedDisclaimer { font-size: 0.8rem; color: var(--text-muted, #8a847a); diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 51885f9..10a60ab 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -22,6 +22,21 @@ const OFSTED_LABELS: Record = { 1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate', }; +const RC_LABELS: Record = { + 1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement', +}; + +const RC_CATEGORIES = [ + { key: 'rc_inclusion' as const, label: 'Inclusion' }, + { key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' }, + { key: 'rc_achievement' as const, label: 'Achievement' }, + { key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' }, + { key: 'rc_personal_development' as const, label: 'Personal Development' }, + { key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' }, + { key: 'rc_early_years' as const, label: 'Early Years' }, + { key: 'rc_sixth_form' as const, label: 'Sixth Form' }, +]; + // 2023 national averages for context const NATIONAL_AVG = { rwm_expected: 60, @@ -179,11 +194,11 @@ export function SchoolDetailView({ - {/* Ofsted Rating */} + {/* Ofsted Rating / Report Card */} {ofsted && (

- Ofsted Rating + {ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'} {ofsted.inspection_date && ( Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} @@ -198,43 +213,82 @@ export function SchoolDetailView({ Full report ↗

-
- - {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} - - {ofsted.previous_overall != null && - ofsted.previous_overall !== ofsted.overall_effectiveness && ( - - Previously: {OFSTED_LABELS[ofsted.previous_overall]} - - )} -
-

- From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools. -

- {parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && ( -

- {Math.round(parentView.q_recommend_pct)}% of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses) -

- )} -
- {[ - { label: 'Quality of Teaching', value: ofsted.quality_of_education }, - { label: 'Behaviour in School', value: ofsted.behaviour_attitudes }, - { label: 'Pupils\' Wider Development', value: ofsted.personal_development }, - { label: 'School Leadership', value: ofsted.leadership_management }, - ...(ofsted.early_years_provision != null - ? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }] - : []), - ].map(({ label, value }) => value != null && ( -
-
{label}
-
- {OFSTED_LABELS[value]} + + {ofsted.framework === 'ReportCard' ? ( + /* ── New Report Card layout ── */ + <> +

+ From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas. +

+ {ofsted.rc_safeguarding_met != null && ( +
+ Safeguarding + + {ofsted.rc_safeguarding_met ? 'Met' : 'Not met'} +
+ )} +
+ {RC_CATEGORIES.map(({ key, label }) => { + const value = ofsted[key] as number | null; + return value != null ? ( +
+
{label}
+
+ {RC_LABELS[value]} +
+
+ ) : null; + })}
- ))} -
+ {parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && ( +

+ {Math.round(parentView.q_recommend_pct)}% of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses) +

+ )} + + ) : ( + /* ── Old OEIF layout ── */ + <> +
+ + {ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'} + + {ofsted.previous_overall != null && + ofsted.previous_overall !== ofsted.overall_effectiveness && ( + + Previously: {OFSTED_LABELS[ofsted.previous_overall]} + + )} +
+

+ From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools. +

+ {parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && ( +

+ {Math.round(parentView.q_recommend_pct)}% of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses) +

+ )} +
+ {[ + { label: 'Quality of Teaching', value: ofsted.quality_of_education }, + { label: 'Behaviour in School', value: ofsted.behaviour_attitudes }, + { label: 'Pupils\' Wider Development', value: ofsted.personal_development }, + { label: 'School Leadership', value: ofsted.leadership_management }, + ...(ofsted.early_years_provision != null + ? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }] + : []), + ].map(({ label, value }) => value != null && ( +
+
{label}
+
+ {OFSTED_LABELS[value]} +
+
+ ))} +
+ + )}
)} diff --git a/nextjs-app/docker-compose.yml b/nextjs-app/docker-compose.yml index d00b6f5..ccecbdc 100644 --- a/nextjs-app/docker-compose.yml +++ b/nextjs-app/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: nextjs: build: diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 515a049..ba9ecb7 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -65,6 +65,10 @@ export interface School { // ============================================================================ export interface OfstedInspection { + framework: 'OEIF' | 'ReportCard' | null; + inspection_date: string | null; + inspection_type: string | null; + // OEIF fields (old framework, pre-Nov 2025) overall_effectiveness: 1 | 2 | 3 | 4 | null; quality_of_education: number | null; behaviour_attitudes: number | null; @@ -72,8 +76,17 @@ export interface OfstedInspection { leadership_management: number | null; early_years_provision: number | null; previous_overall: number | null; - inspection_date: string | null; - inspection_type: string | null; + // Report Card fields (new framework, from Nov 2025) + // 1=Exceptional 2=Strong 3=Expected standard 4=Needs attention 5=Urgent improvement + rc_safeguarding_met: boolean | null; + rc_inclusion: number | null; + rc_curriculum_teaching: number | null; + rc_achievement: number | null; + rc_attendance_behaviour: number | null; + rc_personal_development: number | null; + rc_leadership_governance: number | null; + rc_early_years: number | null; + rc_sixth_form: number | null; } export interface OfstedParentView {