Compare commits

..
Author SHA1 Message Date
tudor e39aef2935 Merge pull request 'fix(compare): sticky school bar hidden behind the site header' (#44) from fix/schoolbar-sticky-offset into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 39s
Reviewed-on: #44
2026-07-15 12:02:29 +00:00
13 changed files with 55 additions and 178 deletions
-1
View File
@@ -577,7 +577,6 @@ def compute_benchmarks(df: pd.DataFrame) -> dict:
"eal_pct": _median(sub, "eal_pct"), "eal_pct": _median(sub, "eal_pct"),
"sen_support_pct": _median(sub, "sen_support_pct"), "sen_support_pct": _median(sub, "sen_support_pct"),
"disadvantaged_pct": _median(sub, "disadvantaged_pct"), "disadvantaged_pct": _median(sub, "disadvantaged_pct"),
"fsm_pct": _median(sub, "fsm_pct"),
"median_pupils": median_pupils, "median_pupils": median_pupils,
} }
if with_disadvantaged: if with_disadvantaged:
+9 -12
View File
@@ -17,33 +17,33 @@ def _df():
# weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0 # weighted = (40*100 + 60*300) / 400 = 55.0 ; unweighted mean = 50.0
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=100,
rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0, rwm_expected_disadvantaged_pct=40.0, eal_pct=10.0,
sen_support_pct=10.0, disadvantaged_pct=20.0, fsm_pct=15.0, total_pupils=200), sen_support_pct=10.0, disadvantaged_pct=20.0, total_pupils=200),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=300,
rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0, rwm_expected_disadvantaged_pct=60.0, eal_pct=20.0,
sen_support_pct=14.0, disadvantaged_pct=24.0, fsm_pct=17.0, total_pupils=280), sen_support_pct=14.0, disadvantaged_pct=24.0, total_pupils=280),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=np.nan,
rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0, rwm_expected_disadvantaged_pct=99.0, eal_pct=30.0,
sen_support_pct=18.0, disadvantaged_pct=30.0, fsm_pct=19.0, total_pupils=300), sen_support_pct=18.0, disadvantaged_pct=30.0, total_pupils=300),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=50,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan, rwm_expected_disadvantaged_pct=np.nan, eal_pct=np.nan,
sen_support_pct=np.nan, disadvantaged_pct=np.nan, fsm_pct=np.nan, total_pupils=np.nan), sen_support_pct=np.nan, disadvantaged_pct=np.nan, total_pupils=np.nan),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=40,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0, rwm_expected_disadvantaged_pct=np.nan, eal_pct=40.0,
sen_support_pct=20.0, disadvantaged_pct=40.0, fsm_pct=21.0, total_pupils=350), sen_support_pct=20.0, disadvantaged_pct=40.0, total_pupils=350),
dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60, dict(year=LATEST, attainment_8_score=np.nan, eligible_pupils=60,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0, rwm_expected_disadvantaged_pct=np.nan, eal_pct=50.0,
sen_support_pct=22.0, disadvantaged_pct=44.0, fsm_pct=23.0, total_pupils=400), sen_support_pct=22.0, disadvantaged_pct=44.0, total_pupils=400),
# Two secondary schools (attainment_8 non-null) # Two secondary schools (attainment_8 non-null)
dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180, dict(year=LATEST, attainment_8_score=45.0, eligible_pupils=180,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0, rwm_expected_disadvantaged_pct=np.nan, eal_pct=15.0,
sen_support_pct=12.0, disadvantaged_pct=22.0, fsm_pct=12.0, total_pupils=1000), sen_support_pct=12.0, disadvantaged_pct=22.0, total_pupils=1000),
dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200, dict(year=LATEST, attainment_8_score=50.0, eligible_pupils=200,
rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0, rwm_expected_disadvantaged_pct=np.nan, eal_pct=25.0,
sen_support_pct=16.0, disadvantaged_pct=26.0, fsm_pct=14.0, total_pupils=1200), sen_support_pct=16.0, disadvantaged_pct=26.0, total_pupils=1200),
# An older-year primary row that must NOT influence anything # An older-year primary row that must NOT influence anything
dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500, dict(year=202324, attainment_8_score=np.nan, eligible_pupils=500,
rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0, rwm_expected_disadvantaged_pct=1.0, eal_pct=99.0,
sen_support_pct=99.0, disadvantaged_pct=99.0, fsm_pct=99.0, total_pupils=9999), sen_support_pct=99.0, disadvantaged_pct=99.0, total_pupils=9999),
] ]
return pd.DataFrame(rows) return pd.DataFrame(rows)
@@ -59,8 +59,6 @@ def test_medians_ignore_nan_and_older_years():
assert b["year"] == LATEST assert b["year"] == LATEST
# eal medians over [10,20,30,40,50] = 30 # eal medians over [10,20,30,40,50] = 30
assert b["primary"]["eal_pct"] == 30.0 assert b["primary"]["eal_pct"] == 30.0
# fsm medians over [15,17,19,21,23] = 19
assert b["primary"]["fsm_pct"] == 19.0
# median pupils over [200,280,300,350,400] = 300 # median pupils over [200,280,300,350,400] = 300
assert b["primary"]["median_pupils"] == 300 assert b["primary"]["median_pupils"] == 300
@@ -68,7 +66,6 @@ def test_medians_ignore_nan_and_older_years():
def test_secondary_block_has_no_disadvantaged_rwm(): def test_secondary_block_has_no_disadvantaged_rwm():
b = compute_benchmarks(_df()) b = compute_benchmarks(_df())
assert "disadvantaged_rwm_expected_pct" not in b["secondary"] assert "disadvantaged_rwm_expected_pct" not in b["secondary"]
assert b["secondary"]["fsm_pct"] == 13.0
assert b["secondary"]["median_pupils"] == 1100 assert b["secondary"]["median_pupils"] == 1100
@@ -126,13 +126,6 @@ describe('ofstedDisplay', () => {
expect(ofstedDisplay(ofsted({})).kind).toBe('none'); expect(ofstedDisplay(ofsted({})).kind).toBe('none');
}); });
it('identifies transitional inspections without overall grades', () => {
const transitional = ofstedDisplay(
ofsted({ overall_effectiveness: null, inspection_date: '2024-11-05' }),
);
expect(transitional.kind).toBe('transitional');
});
it('uses the four legacy grade words', () => { it('uses the four legacy grade words', () => {
expect(OFSTED_LEGACY_GRADES).toEqual({ expect(OFSTED_LEGACY_GRADES).toEqual({
1: 'Outstanding', 1: 'Outstanding',
+11 -18
View File
@@ -142,23 +142,19 @@ export function ComparisonView({
}); });
}, [urnKey, isInitialized]); }, [urnKey, isInitialized]);
const primarySchools = selectedSchools.filter((school) => { // Classify schools by phase using comparison data
const classifySchool = (school: School): 'primary' | 'secondary' => {
const info = comparisonData?.[school.urn]?.school_info; const info = comparisonData?.[school.urn]?.school_info;
const hasPrimaryData = if (info?.attainment_8_score != null) return 'secondary';
info?.rwm_expected_pct != null || if (info?.rwm_expected_pct != null) return 'primary';
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.rwm_expected_pct != null); // Fallback: check yearly data
if (hasPrimaryData) return true; const yearlyData = comparisonData?.[school.urn]?.yearly_data;
return school.phase?.toLowerCase().includes('primary') || false; if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary';
}); return 'primary';
};
const secondarySchools = selectedSchools.filter((school) => { const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary');
const info = comparisonData?.[school.urn]?.school_info; const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary');
const hasSecondaryData =
info?.attainment_8_score != null ||
comparisonData?.[school.urn]?.yearly_data?.some((d) => d.attainment_8_score != null);
if (hasSecondaryData) return true;
return school.phase?.toLowerCase().includes('secondary') || false;
});
// Auto-select tab with more schools and sync the metric to match the phase. // Auto-select tab with more schools and sync the metric to match the phase.
useEffect(() => { useEffect(() => {
@@ -379,7 +375,6 @@ export function ComparisonView({
data={activeComparisonData} data={activeComparisonData}
nationalAverages={nationalAverages} nationalAverages={nationalAverages}
benchmarks={benchmarks} benchmarks={benchmarks}
isSecondary={!isPrimary}
/> />
<CompareOfsted schools={activeSchools} data={activeComparisonData} /> <CompareOfsted schools={activeSchools} data={activeComparisonData} />
<CompareAcademics <CompareAcademics
@@ -387,14 +382,12 @@ export function ComparisonView({
data={activeComparisonData} data={activeComparisonData}
nationalAverages={nationalAverages} nationalAverages={nationalAverages}
benchmarks={benchmarks} benchmarks={benchmarks}
isSecondary={!isPrimary}
/> />
<CompareAdmissions schools={activeSchools} data={activeComparisonData} /> <CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareCommunity <CompareCommunity
schools={activeSchools} schools={activeSchools}
data={activeComparisonData} data={activeComparisonData}
benchmarks={benchmarks} benchmarks={benchmarks}
isSecondary={!isPrimary}
/> />
<TrendsExplorer <TrendsExplorer
schools={activeSchools} schools={activeSchools}
@@ -125,17 +125,15 @@ export function CompareAcademics({
data, data,
nationalAverages, nationalAverages,
benchmarks, benchmarks,
isSecondary: propIsSecondary,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages; nationalAverages?: NationalAverages;
benchmarks?: Benchmarks; benchmarks?: Benchmarks;
isSecondary?: boolean;
}) { }) {
const urns = schools.map((school) => school.urn); const urns = schools.map((school) => school.urn);
const schoolNames = schools.map((school) => school.school_name); const schoolNames = schools.map((school) => school.school_name);
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some( const isSecondary = schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
@@ -47,16 +47,14 @@ export function CompareAtAGlance({
data, data,
nationalAverages, nationalAverages,
benchmarks, benchmarks,
isSecondary: propIsSecondary,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
nationalAverages?: NationalAverages; nationalAverages?: NationalAverages;
benchmarks?: Benchmarks; benchmarks?: Benchmarks;
isSecondary?: boolean;
}) { }) {
const urns = schools.map((school) => school.urn); const urns = schools.map((school) => school.urn);
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some( const isSecondary = schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct'; const headlineKey = isSecondary ? 'attainment_8_score' : 'rwm_expected_pct';
@@ -85,14 +83,6 @@ export function CompareAtAGlance({
{display.carriedForward && <span className={s.small}>Grade carried forward</span>} {display.carriedForward && <span className={s.small}>Grade carried forward</span>}
</> </>
)} )}
{display.kind === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
</>
)}
{display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>} {display.kind === 'none' && <span className={s.small}>No inspection in our dataset</span>}
</Cell> </Cell>
); );
@@ -20,27 +20,24 @@ export function CompareCommunity({
schools, schools,
data, data,
benchmarks, benchmarks,
isSecondary: propIsSecondary,
}: { }: {
schools: School[]; schools: School[];
data: Record<string, ComparisonData>; data: Record<string, ComparisonData>;
benchmarks?: Benchmarks; benchmarks?: Benchmarks;
isSecondary?: boolean;
}) { }) {
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some( const isSecondary = schools.some(
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null, (school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
); );
const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary; const bench = isSecondary ? benchmarks?.secondary : benchmarks?.primary;
const fsmChip = (value: number | null) => { const fsmChip = (value: number | null) => {
const anchor = bench?.fsm_pct ?? bench?.disadvantaged_pct ?? null; if (value == null || bench?.disadvantaged_pct == null) return null;
if (value == null || anchor == null) return null; const v = verdict(value, bench.disadvantaged_pct, 3);
const v = verdict(value, anchor, 3);
return ( return (
<Chip tone="neutral"> <Chip tone="neutral">
{v === 'above' && `Above the state-school average (${Math.round(anchor)}%)`} {v === 'above' && 'Above the state-school average'}
{v === 'close' && `About the state-school average (${Math.round(anchor)}%)`} {v === 'close' && 'About the state-school average'}
{v === 'below' && `Below the state-school average (${Math.round(anchor)}%)`} {v === 'below' && 'Below the state-school average'}
</Chip> </Chip>
); );
}; };
@@ -51,18 +51,6 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
</> </>
); );
} }
if (display.kind === 'transitional') {
return (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
No overall grade
</span>
<span className={s.small}>
Inspected under transitional framework (sub-judgements only)
</span>
</>
);
}
return ( return (
<> <>
<span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}> <span className={`${s.badge} ${display.grade <= 2 ? s.badgeGood : display.grade === 3 ? s.badgeWarn : s.badgeBad}`}>
+1 -7
View File
@@ -102,7 +102,6 @@ export type OfstedDisplay =
| { kind: 'none' } | { kind: 'none' }
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false } | { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true } | { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
| { kind: 'transitional' }
| { kind: 'report_card'; summary: ReportCardSummary }; | { kind: 'report_card'; summary: ReportCardSummary };
export function ofstedDisplay( export function ofstedDisplay(
@@ -118,12 +117,7 @@ export function ofstedDisplay(
const grade = ofsted.overall_effectiveness; const grade = ofsted.overall_effectiveness;
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined; const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined;
if (grade == null || gradeLabel === undefined) { if (grade == null || gradeLabel === undefined) return { kind: 'none' };
if (ofsted.inspection_date) {
return { kind: 'transitional' };
}
return { kind: 'none' };
}
if (ofsted.grade_source === 'ungraded_carried_forward') { if (ofsted.grade_source === 'ungraded_carried_forward') {
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true }; return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
-1
View File
@@ -357,7 +357,6 @@ export interface BenchmarkBlock {
eal_pct: number | null; eal_pct: number | null;
sen_support_pct: number | null; sen_support_pct: number | null;
disadvantaged_pct: number | null; disadvantaged_pct: number | null;
fsm_pct?: number | null;
median_pupils: number | null; median_pupils: number | null;
/** Primary only — weighted by cohort size. */ /** Primary only — weighted by cohort size. */
disadvantaged_rwm_expected_pct?: number | null; disadvantaged_rwm_expected_pct?: number | null;
-3
View File
@@ -49,9 +49,6 @@ plugins:
- name: mi_url - name: mi_url
kind: string kind: string
description: Ofsted Management Information download URL description: Ofsted Management Information download URL
- name: independent_mi_url
kind: string
description: Ofsted Independent Schools Management Information download URL
- name: tap-uk-fbit - name: tap-uk-fbit
namespace: uk_fbit namespace: uk_fbit
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
import io import io
import re import re
@@ -15,28 +14,20 @@ GOV_UK_PAGE = (
"monthly-management-information-ofsteds-school-inspections-outcomes" "monthly-management-information-ofsteds-school-inspections-outcomes"
) )
INDEPENDENT_GOV_UK_PAGE = (
"https://www.gov.uk/government/statistical-data-sets/"
"non-association-independent-schools-inspections-and-outcomes-management-information"
)
# Column name → internal field, in priority order (first match wins). # Column name → internal field, in priority order (first match wins).
# Handles both current and older file formats. # Handles both current and older file formats.
COLUMN_PRIORITY = { COLUMN_PRIORITY = {
"urn": ["URN", "Urn", "urn"], "urn": ["URN", "Urn", "urn"],
"inspection_date": [ "inspection_date": [
"Inspection start date of latest OEIF graded inspection", "Inspection start date of latest OEIF graded inspection",
"Inspection start date of latest OEIF standard inspection",
"Inspection start date", "Inspection start date",
"Inspection date", "Inspection date",
], ],
"inspection_type": [ "inspection_type": [
"Inspection type of latest OEIF graded inspection", "Inspection type of latest OEIF graded inspection",
"Inspection type of latest OEIF standard inspection",
"Inspection type", "Inspection type",
], ],
"event_type_grouping": [ "event_type_grouping": [
"Event type grouping of latest OEIF standard inspection",
"Event type grouping", "Event type grouping",
"Inspection type grouping", "Inspection type grouping",
], ],
@@ -61,12 +52,10 @@ COLUMN_PRIORITY = {
"Effectiveness of leadership and management", "Effectiveness of leadership and management",
], ],
"early_years_provision": [ "early_years_provision": [
"Latest OEIF early years provision (where applicable)",
"Latest OEIF early years provision", "Latest OEIF early years provision",
"Early years provision (where applicable)", "Early years provision (where applicable)",
], ],
"sixth_form_provision": [ "sixth_form_provision": [
"Latest OEIF sixth form provision (where applicable)",
"Latest OEIF sixth form provision", "Latest OEIF sixth form provision",
"Sixth form provision (where applicable)", "Sixth form provision (where applicable)",
], ],
@@ -79,7 +68,12 @@ COLUMN_PRIORITY = {
"ungraded_inspection_date": [ "ungraded_inspection_date": [
"Date of latest ungraded inspection", "Date of latest ungraded inspection",
], ],
# Report Card fields (post-Nov 2025 framework). # Report Card fields (post-Nov 2025 framework). Confirmed verbatim MI
# headers per diagnose_compare_gaps.py's Task 1(c) findings. No MI column
# currently exists for early-years or sixth-form report-card grades, so
# those two fields are deliberately omitted here (see schema below) --
# they stay absent from every record, same as the existing `report_url`
# pattern for fields with no COLUMN_PRIORITY entry.
"rc_safeguarding_met": ["Safeguarding standards"], "rc_safeguarding_met": ["Safeguarding standards"],
"rc_inclusion": ["Inclusion"], "rc_inclusion": ["Inclusion"],
"rc_curriculum_teaching": ["Curriculum and teaching"], "rc_curriculum_teaching": ["Curriculum and teaching"],
@@ -87,13 +81,6 @@ COLUMN_PRIORITY = {
"rc_attendance_behaviour": ["Attendance and behaviour"], "rc_attendance_behaviour": ["Attendance and behaviour"],
"rc_personal_development": ["Personal development and wellbeing"], "rc_personal_development": ["Personal development and wellbeing"],
"rc_leadership_governance": ["Leadership and governance"], "rc_leadership_governance": ["Leadership and governance"],
"rc_early_years": ["Early years (where applicable)"],
"rc_sixth_form": ["Post-16 provision (where applicable)"],
"report_url": [
"Web Link (opens in new window)",
"Web link to Ofsted provider page",
"Web link",
],
} }
@@ -116,51 +103,6 @@ def discover_csv_url() -> str | None:
return matches[0] if matches else None return matches[0] if matches else None
def discover_independent_csv_url() -> str | None:
"""Scrape GOV.UK page to find the latest independent schools MI CSV download link."""
resp = requests.get(INDEPENDENT_GOV_UK_PAGE, timeout=30)
resp.raise_for_status()
# Look for CSV attachment links
csv_links = re.findall(
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
resp.text,
)
if not csv_links:
# Fall back to ODS
csv_links = re.findall(
r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
resp.text,
)
months = {
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12
}
parsed_links = []
for link in csv_links:
normalized_link = link.lower().replace('-', '_')
if 'most_recent' not in normalized_link:
continue
match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized_link)
if match:
day, month_str, year = match.groups()
month = months.get(month_str)
if month:
try:
dt = datetime(int(year), month, int(day))
parsed_links.append((dt, link))
except ValueError:
continue
parsed_links.sort(reverse=True)
if parsed_links:
return parsed_links[0][1]
return csv_links[0] if csv_links else None
class OfstedInspectionsStream(Stream): class OfstedInspectionsStream(Stream):
"""Stream: Ofsted inspection records.""" """Stream: Ofsted inspection records."""
@@ -189,6 +131,8 @@ class OfstedInspectionsStream(Stream):
th.Property("rc_attendance_behaviour", th.StringType), th.Property("rc_attendance_behaviour", th.StringType),
th.Property("rc_personal_development", th.StringType), th.Property("rc_personal_development", th.StringType),
th.Property("rc_leadership_governance", th.StringType), th.Property("rc_leadership_governance", th.StringType),
# No MI column exists for these yet; declared for forward
# compatibility with the mart schema, always emitted as absent/NULL.
th.Property("rc_early_years", th.StringType), th.Property("rc_early_years", th.StringType),
th.Property("rc_sixth_form", th.StringType), th.Property("rc_sixth_form", th.StringType),
th.Property("report_url", th.StringType), th.Property("report_url", th.StringType),
@@ -204,8 +148,15 @@ class OfstedInspectionsStream(Stream):
break break
return mapping return mapping
def _fetch_and_parse_url(self, url: str, pd) -> list[dict]: def get_records(self, context):
"""Download file and parse records.""" import pandas as pd
url = self.config.get("mi_url") or discover_csv_url()
if not url:
self.logger.error("Could not discover Ofsted MI download URL")
return
self.logger.info("Downloading Ofsted MI: %s", url)
resp = requests.get(url, timeout=120) resp = requests.get(url, timeout=120)
resp.raise_for_status() resp.raise_for_status()
@@ -221,6 +172,8 @@ class OfstedInspectionsStream(Stream):
lines = text.split("\n") lines = text.split("\n")
header_idx = 0 header_idx = 0
for i, line in enumerate(lines[:20]): for i, line in enumerate(lines[:20]):
# Match lines where URN appears as a CSV field (start or after comma),
# not as a substring of words like "turn" or "return".
if re.search(r'(?:^|,)\s*URN\s*(?:,|$)', line): if re.search(r'(?:^|,)\s*URN\s*(?:,|$)', line):
header_idx = i header_idx = i
break break
@@ -238,38 +191,16 @@ class OfstedInspectionsStream(Stream):
for _, row in df.iterrows(): for _, row in df.iterrows():
record = {} record = {}
for field, col in col_map.items(): for field, col in col_map.items():
val = row.get(col, None) record[field] = row.get(col, None)
if pd.isna(val):
val = None
record[field] = val
# Cast URN # Cast URN
try: try:
record["urn"] = int(record.get("urn")) record["urn"] = int(record["urn"])
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
continue continue
yield record yield record
def get_records(self, context):
import pandas as pd
# 1. State-funded schools
state_url = self.config.get("mi_url") or discover_csv_url()
if state_url:
self.logger.info("Downloading Ofsted state-funded MI: %s", state_url)
yield from self._fetch_and_parse_url(state_url, pd)
else:
self.logger.error("Could not discover Ofsted state-funded MI download URL")
# 2. Independent schools
ind_url = self.config.get("independent_mi_url") or discover_independent_csv_url()
if ind_url:
self.logger.info("Downloading Ofsted independent MI: %s", ind_url)
yield from self._fetch_and_parse_url(ind_url, pd)
else:
self.logger.error("Could not discover Ofsted independent MI download URL")
class TapUKOfsted(Tap): class TapUKOfsted(Tap):
"""Singer tap for UK Ofsted Management Information.""" """Singer tap for UK Ofsted Management Information."""
@@ -278,7 +209,6 @@ class TapUKOfsted(Tap):
config_jsonschema = th.PropertiesList( config_jsonschema = th.PropertiesList(
th.Property("mi_url", th.StringType, description="Direct URL to Ofsted MI file"), th.Property("mi_url", th.StringType, description="Direct URL to Ofsted MI file"),
th.Property("independent_mi_url", th.StringType, description="Direct URL to Ofsted Independent Schools MI file"),
).to_dict() ).to_dict()
def discover_streams(self): def discover_streams(self):
@@ -46,10 +46,12 @@ renamed as (
{{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour, {{ parse_report_card_grade('rc_attendance_behaviour') }}::integer as rc_attendance_behaviour,
{{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development, {{ parse_report_card_grade('rc_personal_development') }}::integer as rc_personal_development,
{{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance, {{ parse_report_card_grade('rc_leadership_governance') }}::integer as rc_leadership_governance,
{{ parse_report_card_grade('rc_early_years') }}::integer as rc_early_years, -- No MI column exists for these yet (see tap.py); the tap never
{{ parse_report_card_grade('rc_sixth_form') }}::integer as rc_sixth_form, -- emits rc_early_years/rc_sixth_form, so these stay NULL.
null::integer as rc_early_years,
null::integer as rc_sixth_form,
nullif(trim(report_url), 'NULL') as report_url report_url
from source from source
where urn is not null where urn is not null
and ( and (