feat(ofsted): add Report Card system support alongside legacy OEIF grades
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 47s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m11s
Build and Push Docker Images / Build Integrator (push) Successful in 58s
Build and Push Docker Images / Build Kestra Init (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:03:04 +00:00
parent f5aceb1b54
commit 1c49a135c4
9 changed files with 325 additions and 69 deletions

View File

@@ -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

View File

@@ -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"<OfstedInspection(urn={self.urn}, overall={self.overall_effectiveness})>"
return f"<OfstedInspection(urn={self.urn}, framework={self.framework}, overall={self.overall_effectiveness})>"
class OfstedParentView(Base):

View File

@@ -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)",
}

View File

@@ -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 15."""
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,
)

View File

@@ -15,8 +15,7 @@ export function Footer() {
<div className={styles.section}>
<h3 className={styles.title}>SchoolCompare</h3>
<p className={styles.description}>
Compare primary school KS2 performance across England. Data sourced from UK Government
Compare School Performance.
Compare primary schools across England.
</p>
</div>
@@ -24,14 +23,6 @@ export function Footer() {
<h4 className={styles.sectionTitle}>About</h4>
<ul className={styles.links}>
<li>
<a
href="https://www.compare-school-performance.service.gov.uk/"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
Data Source
</a>
</li>
</ul>
</div>

View File

@@ -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);

View File

@@ -22,6 +22,21 @@ const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
const RC_LABELS: Record<number, string> = {
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({
</div>
</nav>
{/* Ofsted Rating */}
{/* Ofsted Rating / Report Card */}
{ofsted && (
<section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}>
Ofsted Rating
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofsted.inspection_date && (
<span className={styles.ofstedDate}>
Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
@@ -198,43 +213,82 @@ export function SchoolDetailView({
Full report
</a>
</h2>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
</p>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
<div className={styles.metricsGrid}>
{[
{ 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 && (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
{ofsted.framework === 'ReportCard' ? (
/* ── New Report Card layout ── */
<>
<p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p>
{ofsted.rc_safeguarding_met != null && (
<div className={styles.safeguardingRow}>
<span className={styles.safeguardingLabel}>Safeguarding</span>
<span className={ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}>
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
</span>
</div>
)}
<div className={styles.metricsGrid}>
{RC_CATEGORIES.map(({ key, label }) => {
const value = ofsted[key] as number | null;
return value != null ? (
<div key={key} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
{RC_LABELS[value]}
</div>
</div>
) : null;
})}
</div>
))}
</div>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
</>
) : (
/* ── Old OEIF layout ── */
<>
<div className={styles.ofstedHeader}>
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
</span>
{ofsted.previous_overall != null &&
ofsted.previous_overall !== ofsted.overall_effectiveness && (
<span className={styles.ofstedPrevious}>
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
</span>
)}
</div>
<p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
</p>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
<div className={styles.metricsGrid}>
{[
{ 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 && (
<div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
{OFSTED_LABELS[value]}
</div>
</div>
))}
</div>
</>
)}
</section>
)}

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
nextjs:
build:

View File

@@ -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 {