From dbaa15c0995473dd902e6263b59a23a5d98ec56e Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 18:44:29 +0100 Subject: [PATCH] feat(api): expose progress CIs, KS4 banding/gaps, admissions detail, report-card labels Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- backend/data_loader.py | 132 +++++++++++------- .../tests/test_supplementary_enrichment.py | 65 +++++++++ 2 files changed, 149 insertions(+), 48 deletions(-) create mode 100644 backend/tests/test_supplementary_enrichment.py diff --git a/backend/data_loader.py b/backend/data_loader.py index 75c4528..4305e42 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -21,6 +21,7 @@ from .models import ( FactOfstedInspection, FactAdmissions, FactDeprivation, FactFinance, FactPupilCharacteristics, ) +from .ofsted_codes import ofsted_page_url, report_card_labels from .schemas import SCHOOL_TYPE_MAP from .gias_codes import ( ADMISSIONS_POLICY, @@ -190,13 +191,20 @@ _MAIN_QUERY = text(""" p.reading_high_pct, p.reading_avg_score, p.reading_progress, + p.reading_progress_lower_ci, + p.reading_progress_upper_ci, p.writing_expected_pct, p.writing_high_pct, p.writing_progress, + p.writing_progress_lower_ci, + p.writing_progress_upper_ci, + p.writing_working_towards_pct, p.maths_expected_pct, p.maths_high_pct, p.maths_avg_score, p.maths_progress, + p.maths_progress_lower_ci, + p.maths_progress_upper_ci, p.gps_expected_pct, p.gps_high_pct, p.gps_avg_score, @@ -225,6 +233,9 @@ _MAIN_QUERY = text(""" p.progress_8_maths, p.progress_8_ebacc, p.progress_8_open, + p.progress_8_banding, + p.attainment_8_disadvantage_gap, + p.progress_8_disadvantage_gap, p.english_maths_strong_pass_pct, p.english_maths_standard_pass_pct, p.ebacc_entry_pct, @@ -514,6 +525,77 @@ def get_data_info(db: Session = None) -> dict: # SUPPLEMENTARY DATA — per-school detail page # ============================================================================= +def _ofsted_block(o, urn: int) -> dict: + """Serialize the latest Ofsted inspection row for API responses. + + `grade_source` records where the effective overall grade came from: + a graded (Section 5) inspection, or carried forward from an ungraded + (Section 8) outcome — materially different claims a UI must be able + to distinguish. `report_card` holds coded+labelled renewed-framework + (Nov 2025) area judgements; safeguarding is a separate boolean and + never appears among the graded areas. + """ + if o.overall_effectiveness is not None: + grade_source = "graded" + overall = o.overall_effectiveness + elif o.ungraded_grade is not None: + # Fall back to the grade parsed from an ungraded (Section 8) outcome + # (e.g. "School remains Good") so the detail page matches the list badge. + grade_source = "ungraded_carried_forward" + overall = o.ungraded_grade + else: + grade_source = None + overall = None + + block = { + "framework": o.framework, + "inspection_date": o.inspection_date.isoformat() if o.inspection_date else None, + "inspection_type": o.inspection_type, + "overall_effectiveness": overall, + "grade_source": grade_source, + "quality_of_education": o.quality_of_education, + "behaviour_attitudes": o.behaviour_attitudes, + "personal_development": o.personal_development, + "leadership_management": o.leadership_management, + "early_years_provision": o.early_years_provision, + "sixth_form_provision": o.sixth_form_provision, + "previous_overall": None, # Not available in new schema + "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, + "report_url": o.report_url, + "ofsted_page_url": ofsted_page_url(urn), + } + block["report_card"] = report_card_labels(block) + return block + + +def _admissions_row_dict(a) -> dict: + """Serialize one fact_admissions row for API responses.""" + return { + "year": a.year, + "school_phase": a.school_phase, + "places_offered": a.places_offered, + "total_applications": a.total_applications, + "first_preference_applications": a.first_preference_applications, + "first_preference_offers": a.first_preference_offers, + "first_preference_offer_pct": a.first_preference_offer_pct, + "oversubscription_ratio": a.oversubscription_ratio, + "oversubscribed": a.oversubscribed, + "total_offers": a.total_offers, + "second_preference_offers": a.second_preference_offers, + "third_preference_offers": a.third_preference_offers, + "cross_la_applications": a.cross_la_applications, + "cross_la_offers": a.cross_la_offers, + } + + def get_supplementary_data(db: Session, urn: int) -> dict: """Fetch all supplementary data for a single school URN.""" result = {} @@ -532,40 +614,7 @@ def get_supplementary_data(db: Session, urn: int) -> dict: # Latest Ofsted inspection o = safe_query(FactOfstedInspection, "urn", "inspection_date") - result["ofsted"] = ( - { - "framework": o.framework, - "inspection_date": o.inspection_date.isoformat() if o.inspection_date else None, - "inspection_type": o.inspection_type, - # Fall back to the grade parsed from an ungraded (Section 8) outcome - # (e.g. "School remains Good") when there's no graded grade, so the - # detail page matches the list badge. - "overall_effectiveness": ( - o.overall_effectiveness - if o.overall_effectiveness is not None - else o.ungraded_grade - ), - "quality_of_education": o.quality_of_education, - "behaviour_attitudes": o.behaviour_attitudes, - "personal_development": o.personal_development, - "leadership_management": o.leadership_management, - "early_years_provision": o.early_years_provision, - "sixth_form_provision": o.sixth_form_provision, - "previous_overall": None, # Not available in new schema - "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, - "report_url": o.report_url, - } - if o - else None - ) + result["ofsted"] = _ofsted_block(o, urn) if o else None # Census (latest year of fact_pupil_characteristics) pc = safe_query(FactPupilCharacteristics, "urn", "year") @@ -583,19 +632,6 @@ def get_supplementary_data(db: Session, urn: int) -> dict: ) # Admissions — all years, oldest first (for the multi-year trend view). - def _admissions_row(a): - return { - "year": a.year, - "school_phase": a.school_phase, - "places_offered": a.places_offered, - "total_applications": a.total_applications, - "first_preference_applications": a.first_preference_applications, - "first_preference_offers": a.first_preference_offers, - "first_preference_offer_pct": a.first_preference_offer_pct, - "oversubscription_ratio": a.oversubscription_ratio, - "oversubscribed": a.oversubscribed, - } - try: admissions_rows = ( db.query(FactAdmissions) @@ -609,7 +645,7 @@ def get_supplementary_data(db: Session, urn: int) -> dict: db.rollback() admissions_rows = [] - history = [_admissions_row(a) for a in admissions_rows] + history = [_admissions_row_dict(a) for a in admissions_rows] result["admissions_history"] = history # Keep the single latest-year object for backwards-compatible consumers # (hero chips, etc.). diff --git a/backend/tests/test_supplementary_enrichment.py b/backend/tests/test_supplementary_enrichment.py new file mode 100644 index 0000000..fe6e746 --- /dev/null +++ b/backend/tests/test_supplementary_enrichment.py @@ -0,0 +1,65 @@ +"""Supplementary-block enrichment for the compare redesign: report-card +labels, provider-page URL, graded-vs-carried-forward provenance, and the +admissions preference/cross-LA detail promoted in the data-foundation PR.""" + +import types + +from backend.data_loader import _admissions_row_dict, _ofsted_block + + +def _row(**kw): + base = dict( + framework="RC", inspection_date=None, inspection_type=None, + overall_effectiveness=None, quality_of_education=None, + behaviour_attitudes=None, personal_development=None, + leadership_management=None, early_years_provision=None, + sixth_form_provision=None, ungraded_outcome=None, ungraded_grade=None, + rc_safeguarding_met=None, rc_inclusion=None, rc_curriculum_teaching=None, + rc_achievement=None, rc_attendance_behaviour=None, + rc_personal_development=None, rc_leadership_governance=None, + rc_early_years=None, rc_sixth_form=None, report_url=None, + ) + base.update(kw) + return types.SimpleNamespace(**base) + + +def test_report_card_block_and_provider_url(): + o = _row(rc_achievement=2, rc_inclusion=3, rc_safeguarding_met=True) + block = _ofsted_block(o, urn=100140) + assert block["report_card"]["rc_achievement"]["label"] == "Strong standard" + assert "rc_safeguarding_met" not in block["report_card"] + assert block["rc_safeguarding_met"] is True + assert block["ofsted_page_url"] == "https://reports.ofsted.gov.uk/provider/21/100140" + + +def test_grade_source_graded_vs_carried_forward(): + assert _ofsted_block(_row(overall_effectiveness=1), urn=1)["grade_source"] == "graded" + carried = _ofsted_block(_row(ungraded_grade=2), urn=1) + assert carried["grade_source"] == "ungraded_carried_forward" + assert carried["overall_effectiveness"] == 2 + assert _ofsted_block(_row(), urn=1)["grade_source"] is None + + +def test_ofsted_block_keeps_existing_keys(): + block = _ofsted_block(_row(overall_effectiveness=2, quality_of_education=2), urn=1) + for key in ("framework", "inspection_date", "overall_effectiveness", + "quality_of_education", "rc_inclusion", "report_url"): + assert key in block + + +def test_admissions_row_new_fields(): + a = types.SimpleNamespace( + year=202627, school_phase="Primary", places_offered=80, + total_applications=185, first_preference_applications=74, + first_preference_offers=74, first_preference_offer_pct=100.0, + oversubscription_ratio=0.925, oversubscribed=False, + total_offers=80, second_preference_offers=4, third_preference_offers=2, + cross_la_applications=12, cross_la_offers=3, + ) + d = _admissions_row_dict(a) + for k in ("total_offers", "second_preference_offers", "third_preference_offers", + "cross_la_applications", "cross_la_offers"): + assert d[k] == getattr(a, k) + # Existing keys unchanged + assert d["first_preference_offer_pct"] == 100.0 + assert d["oversubscribed"] is False