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

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