From 0596ee5e2cabc6432c24123f8a164e0b9159eff4 Mon Sep 17 00:00:00 2001 From: kcar Date: Wed, 27 May 2026 05:59:06 +0100 Subject: [PATCH] feat(seed): Greenfield full timetable seed with classes and student enrollment seed_greenfield_timetable.py creates the complete school data set: - POST /timetable/setup: school_timetables, academic_years, terms, weeks, days - POST /timetable/materialize-periods: 1624 academic_periods (203 days x 8 periods) - 17 classes (Physics/Maths/English/History/Science, Yr7-12) with correct metadata - class_teachers links (primary teacher per class) - teacher timetable init + slot assignments (class_id FK patched onto slots) - class_students enrollment: student1->Yr9 (5 classes), student2->Yr10 (4), student3->Yr11 (2) - POST /timetable/materialize: 1462 taught_lessons all with class_id populated - POST /admin/seed-timetable endpoint wired in platform_admin_router Co-Authored-By: Claude Sonnet 4.6 --- .../database/tools/platform_admin_router.py | 12 + .../seed_greenfield_timetable.py | 484 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 run/initialization/seed_greenfield_timetable.py diff --git a/routers/database/tools/platform_admin_router.py b/routers/database/tools/platform_admin_router.py index 50619fd..bb6ac9f 100644 --- a/routers/database/tools/platform_admin_router.py +++ b/routers/database/tools/platform_admin_router.py @@ -146,3 +146,15 @@ async def seed_environment( loop = asyncio.get_event_loop() result = await loop.run_in_executor(None, _seed) return {"status": "ok", **result} + + +@router.post("/seed-timetable") +async def seed_greenfield_timetable( + _: dict = Depends(require_platform_admin), +) -> Dict[str, Any]: + """Seed full timetable + taught lessons for Greenfield Academy. Platform admin only.""" + import asyncio + from run.initialization.seed_greenfield_timetable import seed as _seed + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, _seed) + return {"status": "ok", **result} diff --git a/run/initialization/seed_greenfield_timetable.py b/run/initialization/seed_greenfield_timetable.py new file mode 100644 index 0000000..6d1968e --- /dev/null +++ b/run/initialization/seed_greenfield_timetable.py @@ -0,0 +1,484 @@ +""" +seed_greenfield_timetable.py — Full timetable + class + student seed for Greenfield Academy. + +Flow: + 1. POST /timetable/setup — academic year, 3 terms, periods → Supabase + 2. POST /timetable/materialize-periods — academic_periods rows (days × template) + 3. Create classes — 17 classes with correct metadata + 4. Add teachers to classes — primary teacher per class + 5. POST /timetable/init + slots — TeacherTimetable + slot assignments + 6. Patch slot class_ids — write class_id FK onto teacher_timetable_slots + 7. Enroll students in classes — student1→Yr9, student2→Yr10, student3→Yr11 + 8. POST /timetable/materialize — taught_lessons with class_id populated + 9. POST /timetable/sync-lessons — Neo4j TaughtLesson nodes (B.10) + +Run inside ccapi container: + python3 -c "from run.initialization.seed_greenfield_timetable import seed; seed()" +""" +import os +import time +import requests +from typing import Dict, Any, Optional, List + +SUPA_URL = os.environ["SUPABASE_URL"] +SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"] +API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000") + +GREENFIELD_ADMIN_EMAIL = "admin@greenfieldacademy.test" +GREENFIELD_ADMIN_PWD = "Admin@Cc2025!" +PWD_TEACHER = "Teacher@Cc2025!" +PWD_STUDENT = "Student@Cc2025!" + +# ─── Period templates ────────────────────────────────────────────────────────── + +PERIODS = [ + {"code": "REG", "name": "Registration", "start_time": "08:45", "end_time": "09:00", "period_type": "registration"}, + {"code": "P1", "name": "Period 1", "start_time": "09:00", "end_time": "10:00", "period_type": "lesson"}, + {"code": "P2", "name": "Period 2", "start_time": "10:00", "end_time": "11:00", "period_type": "lesson"}, + {"code": "BRK", "name": "Break", "start_time": "11:00", "end_time": "11:20", "period_type": "break"}, + {"code": "P3", "name": "Period 3", "start_time": "11:20", "end_time": "12:20", "period_type": "lesson"}, + {"code": "P4", "name": "Period 4", "start_time": "12:20", "end_time": "13:20", "period_type": "lesson"}, + {"code": "LUN", "name": "Lunch", "start_time": "13:20", "end_time": "14:00", "period_type": "break"}, + {"code": "P5", "name": "Period 5", "start_time": "14:00", "end_time": "15:00", "period_type": "lesson"}, +] + +PERIOD_TIMES = {p["code"]: (p["start_time"], p["end_time"]) for p in PERIODS} + +# ─── Academic year ───────────────────────────────────────────────────────────── + +TERMS = [ + {"name": "Autumn Term", "term_number": 1, "start_date": "2025-09-03", "end_date": "2025-12-19"}, + {"name": "Spring Term", "term_number": 2, "start_date": "2026-01-05", "end_date": "2026-04-01"}, + {"name": "Summer Term", "term_number": 3, "start_date": "2026-04-20", "end_date": "2026-07-17"}, +] + +# ─── Class definitions ───────────────────────────────────────────────────────── +# Covers every unique subject_class code in TEACHER_SLOTS. +# key_stage: KS3 = years 7-9, KS4 = years 10-11, KS5 = years 12-13 + +CLASSES = [ + # Physics + {"class_code": "9P/Ph1", "name": "Year 9 Physics Group 1", "subject": "Physics", "year_group": "9", "key_stage": "3", "teacher": "physics@greenfieldacademy.test"}, + {"class_code": "10P/Ph2", "name": "Year 10 Physics Group 2", "subject": "Physics", "year_group": "10", "key_stage": "4", "teacher": "physics@greenfieldacademy.test"}, + {"class_code": "11P/Ph1", "name": "Year 11 Physics Group 1", "subject": "Physics", "year_group": "11", "key_stage": "4", "teacher": "physics@greenfieldacademy.test"}, + {"class_code": "12P/Ph1", "name": "Year 12 Physics Group 1", "subject": "Physics", "year_group": "12", "key_stage": "5", "teacher": "physics@greenfieldacademy.test"}, + # Maths + {"class_code": "9M/Ma1", "name": "Year 9 Maths Group 1", "subject": "Mathematics", "year_group": "9", "key_stage": "3", "teacher": "maths@greenfieldacademy.test"}, + {"class_code": "10M/Ma1", "name": "Year 10 Maths Group 1", "subject": "Mathematics", "year_group": "10", "key_stage": "4", "teacher": "maths@greenfieldacademy.test"}, + {"class_code": "11M/Ma2", "name": "Year 11 Maths Group 2", "subject": "Mathematics", "year_group": "11", "key_stage": "4", "teacher": "maths@greenfieldacademy.test"}, + # English + {"class_code": "7En/1", "name": "Year 7 English Group 1", "subject": "English", "year_group": "7", "key_stage": "3", "teacher": "teacher1@greenfieldacademy.test"}, + {"class_code": "8En/1", "name": "Year 8 English Group 1", "subject": "English", "year_group": "8", "key_stage": "3", "teacher": "teacher1@greenfieldacademy.test"}, + {"class_code": "9En/1", "name": "Year 9 English Group 1", "subject": "English", "year_group": "9", "key_stage": "3", "teacher": "teacher1@greenfieldacademy.test"}, + # History + {"class_code": "8Hs/1", "name": "Year 8 History Group 1", "subject": "History", "year_group": "8", "key_stage": "3", "teacher": "teacher2@greenfieldacademy.test"}, + {"class_code": "9Hs/1", "name": "Year 9 History Group 1", "subject": "History", "year_group": "9", "key_stage": "3", "teacher": "teacher2@greenfieldacademy.test"}, + {"class_code": "10Hs/1", "name": "Year 10 History Group 1", "subject": "History", "year_group": "10", "key_stage": "4", "teacher": "teacher2@greenfieldacademy.test"}, + # Science + {"class_code": "7Sc/1", "name": "Year 7 Science Group 1", "subject": "Science", "year_group": "7", "key_stage": "3", "teacher": "teacher3@greenfieldacademy.test"}, + {"class_code": "8Sc/1", "name": "Year 8 Science Group 1", "subject": "Science", "year_group": "8", "key_stage": "3", "teacher": "teacher3@greenfieldacademy.test"}, + {"class_code": "9Sc/1", "name": "Year 9 Science Group 1", "subject": "Science", "year_group": "9", "key_stage": "3", "teacher": "teacher3@greenfieldacademy.test"}, + {"class_code": "10Sc/1", "name": "Year 10 Science Group 1", "subject": "Science", "year_group": "10", "key_stage": "4", "teacher": "teacher3@greenfieldacademy.test"}, +] + +# ─── Teacher slot assignments ────────────────────────────────────────────────── + +TEACHER_SLOTS = { + "physics@greenfieldacademy.test": [ + ("Monday", "P1", "11P/Ph1"), + ("Monday", "P3", "12P/Ph1"), + ("Tuesday", "P2", "10P/Ph2"), + ("Tuesday", "P4", "9P/Ph1"), + ("Wednesday", "P1", "11P/Ph1"), + ("Wednesday", "P5", "12P/Ph1"), + ("Thursday", "P3", "10P/Ph2"), + ("Thursday", "P5", "9P/Ph1"), + ("Friday", "P2", "11P/Ph1"), + ("Friday", "P4", "12P/Ph1"), + ], + "maths@greenfieldacademy.test": [ + ("Monday", "P2", "10M/Ma1"), + ("Monday", "P4", "11M/Ma2"), + ("Tuesday", "P1", "9M/Ma1"), + ("Tuesday", "P3", "10M/Ma1"), + ("Wednesday", "P2", "11M/Ma2"), + ("Wednesday", "P4", "9M/Ma1"), + ("Thursday", "P1", "10M/Ma1"), + ("Thursday", "P4", "11M/Ma2"), + ("Friday", "P1", "9M/Ma1"), + ("Friday", "P3", "10M/Ma1"), + ], + "teacher1@greenfieldacademy.test": [ + ("Monday", "P1", "7En/1"), + ("Monday", "P5", "8En/1"), + ("Tuesday", "P2", "9En/1"), + ("Wednesday", "P3", "7En/1"), + ("Thursday", "P2", "8En/1"), + ("Friday", "P5", "9En/1"), + ], + "teacher2@greenfieldacademy.test": [ + ("Monday", "P3", "8Hs/1"), + ("Tuesday", "P5", "9Hs/1"), + ("Wednesday", "P1", "10Hs/1"), + ("Thursday", "P2", "8Hs/1"), + ("Friday", "P3", "9Hs/1"), + ], + "teacher3@greenfieldacademy.test": [ + ("Monday", "P4", "7Sc/1"), + ("Tuesday", "P3", "8Sc/1"), + ("Wednesday", "P5", "9Sc/1"), + ("Thursday", "P4", "10Sc/1"), + ("Friday", "P2", "7Sc/1"), + ], +} + +# ─── Student enrollments ─────────────────────────────────────────────────────── +# One student per year-group band — enrolled in all subjects for that year. + +STUDENT_ENROLLMENTS = { + "student1@greenfieldacademy.test": ["9P/Ph1", "9M/Ma1", "9En/1", "9Hs/1", "9Sc/1"], + "student2@greenfieldacademy.test": ["10P/Ph2", "10M/Ma1", "10Hs/1", "10Sc/1"], + "student3@greenfieldacademy.test": ["11P/Ph1", "11M/Ma2"], +} + + +# ─── Helpers ─────────────────────────────────────────────────────────────────── + +def _sb_headers() -> Dict: + return { + "apikey": SERVICE_KEY, + "Authorization": f"Bearer {SERVICE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=representation", + } + + +def _sign_in(email: str, password: str) -> str: + r = requests.post( + f"{SUPA_URL}/auth/v1/token?grant_type=password", + headers={"apikey": SERVICE_KEY, "Content-Type": "application/json"}, + json={"email": email, "password": password}, + ) + r.raise_for_status() + return r.json()["access_token"] + + +def _api(token: str, method: str, path: str, body: Dict = None) -> Dict: + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + r = getattr(requests, method)(f"{API_BASE}{path}", headers=h, json=body) + try: + return r.json() + except Exception: + return {"_raw": r.text, "_status": r.status_code} + + +def _get_profile_id(email: str) -> Optional[str]: + """Look up a profile's UUID by email via Supabase service role.""" + r = requests.get( + f"{SUPA_URL}/rest/v1/profiles", + headers=_sb_headers(), + params={"email": f"eq.{email}", "select": "id", "limit": "1"}, + ) + data = r.json() if r.ok else [] + return data[0]["id"] if data else None + + +def _get_teacher_timetable_id(profile_id: str) -> Optional[str]: + """Return the Supabase teacher_timetables.id for a given profile.""" + r = requests.get( + f"{SUPA_URL}/rest/v1/teacher_timetables", + headers=_sb_headers(), + params={"profile_id": f"eq.{profile_id}", "select": "id", "limit": "1"}, + ) + data = r.json() if r.ok else [] + return data[0]["id"] if data else None + + +def _patch_slot_class_ids(teacher_tt_sb_id: str, class_code_to_id: Dict[str, str]) -> int: + """Update class_id FK on teacher_timetable_slots rows via Supabase service role.""" + patched = 0 + for code, class_uuid in class_code_to_id.items(): + r = requests.patch( + f"{SUPA_URL}/rest/v1/teacher_timetable_slots", + headers=_sb_headers(), + params={ + "teacher_timetable_id": f"eq.{teacher_tt_sb_id}", + "subject_class": f"eq.{code}", + }, + json={"class_id": class_uuid}, + ) + if r.ok: + patched += 1 + return patched + + +# ─── Main seed ───────────────────────────────────────────────────────────────── + +def seed() -> Dict[str, Any]: + print("=" * 60) + print("Greenfield Academy — full timetable + class + student seed") + print("=" * 60) + results: Dict[str, Any] = {} + errors: List[str] = [] + + # ── [1] Sign in as Greenfield admin ─────────────────────────────────────── + print("\n[1] Signing in as admin@greenfieldacademy.test...") + try: + admin_token = _sign_in(GREENFIELD_ADMIN_EMAIL, GREENFIELD_ADMIN_PWD) + print(" ✓ signed in") + except Exception as e: + return {"success": False, "error": str(e)} + + # ── [2] POST /timetable/setup ───────────────────────────────────────────── + print("\n[2] Setting up school timetable (academic year + terms + periods)...") + r = _api(admin_token, "post", "/timetable/setup", { + "year_start": "2025-09-03", + "year_end": "2026-07-17", + "terms": TERMS, + "periods": PERIODS, + }) + if r.get("status") == "ok" or r.get("school_timetable_id") or r.get("timetable_id"): + print(f" ✓ timetable: {r.get('school_timetable_id') or r.get('timetable_id')}") + results["setup"] = "ok" + else: + err = f"timetable/setup: {r}" + print(f" ✗ {err}") + errors.append(err) + results["setup"] = "error" + + # ── [3] POST /timetable/materialize-periods ─────────────────────────────── + print("\n[3] Materializing academic_periods (days × periods_template)...") + r = _api(admin_token, "post", "/timetable/materialize-periods", None) + if r.get("status") == "ok": + print(f" ✓ {r.get('created')} periods created across {r.get('academic_days')} academic days") + results["materialize_periods"] = "ok" + else: + err = f"materialize-periods: {r}" + print(f" ✗ {err}") + errors.append(err) + results["materialize_periods"] = "error" + + # ── [5] Build profile-ID lookup for all teachers + students ─────────────── + print("\n[3] Resolving profile IDs for teachers and students...") + all_emails = ( + list(TEACHER_SLOTS.keys()) + + list(STUDENT_ENROLLMENTS.keys()) + ) + profile_ids: Dict[str, str] = {} + for email in all_emails: + pid = _get_profile_id(email) + if pid: + profile_ids[email] = pid + print(f" ✓ {email} → {pid[:8]}…") + else: + print(f" ✗ profile not found for {email}") + errors.append(f"profile_not_found: {email}") + + # ── [6] Create classes ──────────────────────────────────────────────────── + print(f"\n[4] Creating {len(CLASSES)} classes...") + results["classes"] = {} + class_code_to_id: Dict[str, str] = {} + + for cls in CLASSES: + r = _api(admin_token, "post", "/database/timetable/classes", { + "name": cls["name"], + "class_code": cls["class_code"], + "subject": cls["subject"], + "year_group": cls["year_group"], + "key_stage": cls["key_stage"], + "academic_year": "2025-2026", + }) + class_id = r.get("id") or (r.get("class", {}) or {}).get("id") + if class_id: + class_code_to_id[cls["class_code"]] = class_id + results["classes"][cls["class_code"]] = "ok" + print(f" ✓ {cls['class_code']} → {class_id[:8]}…") + else: + err = f"create class {cls['class_code']}: {r}" + print(f" ✗ {err}") + errors.append(err) + results["classes"][cls["class_code"]] = "error" + time.sleep(0.1) + + # ── [7] Add teachers to their classes ──────────────────────────────────── + print("\n[5] Adding teachers to classes...") + results["class_teachers"] = {} + for cls in CLASSES: + class_id = class_code_to_id.get(cls["class_code"]) + teacher_pid = profile_ids.get(cls["teacher"]) + if not class_id or not teacher_pid: + results["class_teachers"][cls["class_code"]] = "skip" + continue + r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/teachers", { + "teacher_id": teacher_pid, + "is_primary": True, + }) + if r.get("status") == "ok" or r.get("id"): + print(f" ✓ {cls['teacher'].split('@')[0]} → {cls['class_code']}") + results["class_teachers"][cls["class_code"]] = "ok" + else: + err = f"add teacher {cls['class_code']}: {r}" + print(f" ✗ {err}") + errors.append(err) + results["class_teachers"][cls["class_code"]] = "error" + time.sleep(0.1) + + # ── [8] Teacher timetable init + slots ──────────────────────────────────── + print("\n[6] Initialising TeacherTimetable and setting slots for each teacher...") + results["init"] = {} + results["slots"] = {} + teacher_tt_sb_ids: Dict[str, str] = {} # email → teacher_timetables.id + + for teacher_email, slot_tuples in TEACHER_SLOTS.items(): + try: + teacher_token = _sign_in(teacher_email, PWD_TEACHER) + except Exception as e: + err = f"login {teacher_email}: {e}" + print(f" ✗ {err}") + errors.append(err) + results["init"][teacher_email] = "error" + results["slots"][teacher_email] = "error" + continue + + # 6a: init TeacherTimetable + r = _api(teacher_token, "post", "/timetable/init", None) + if r.get("status") == "ok": + print(f" ✓ init {teacher_email}") + results["init"][teacher_email] = "ok" + else: + print(f" ~ init {teacher_email}: {r.get('message', r)} (may already exist)") + results["init"][teacher_email] = "warn" + + # 6b: get timetable_id (Neo4j uuid_string for slot FK) + status_r = _api(teacher_token, "get", "/timetable/status", None) + timetable_id = status_r.get("timetable_id") + if not timetable_id: + err = f"no timetable_id for {teacher_email}: {status_r}" + print(f" ✗ {err}") + errors.append(err) + results["slots"][teacher_email] = "error" + continue + + # 6c: save slots (subject_class text; class_id patched separately) + slot_list = [ + { + "day_of_week": day, + "period_code": code, + "subject_class": cls, + "start_time": PERIOD_TIMES[code][0], + "end_time": PERIOD_TIMES[code][1], + } + for day, code, cls in slot_tuples + ] + r = _api(teacher_token, "post", "/timetable/slots", { + "timetable_id": timetable_id, + "slots": slot_list, + }) + if r.get("status") == "ok" or r.get("created") is not None: + count = r.get("created") or len(slot_list) + print(f" ✓ {teacher_email}: {count} slots") + results["slots"][teacher_email] = "ok" + else: + err = f"slots {teacher_email}: {r}" + print(f" ✗ {err}") + errors.append(err) + results["slots"][teacher_email] = "error" + + # record Supabase teacher_timetable FK for patching + teacher_pid = profile_ids.get(teacher_email) + if teacher_pid: + tt_sb_id = _get_teacher_timetable_id(teacher_pid) + if tt_sb_id: + teacher_tt_sb_ids[teacher_email] = tt_sb_id + + time.sleep(0.3) + + # ── [9] Patch teacher_timetable_slots.class_id ──────────────────────────── + print("\n[7] Patching class_id onto teacher_timetable_slots...") + results["slot_patch"] = {} + for teacher_email, slot_tuples in TEACHER_SLOTS.items(): + tt_sb_id = teacher_tt_sb_ids.get(teacher_email) + if not tt_sb_id: + results["slot_patch"][teacher_email] = "skip" + continue + teacher_codes = {cls for _, _, cls in slot_tuples} + relevant_map = {code: uid for code, uid in class_code_to_id.items() if code in teacher_codes} + n = _patch_slot_class_ids(tt_sb_id, relevant_map) + print(f" ✓ {teacher_email}: {n} slots patched") + results["slot_patch"][teacher_email] = n + + # ── [10] Enroll students in classes ─────────────────────────────────────── + print("\n[8] Enrolling students in classes...") + results["enrollments"] = {} + for student_email, class_codes in STUDENT_ENROLLMENTS.items(): + student_pid = profile_ids.get(student_email) + results["enrollments"][student_email] = {} + if not student_pid: + results["enrollments"][student_email] = "no_profile" + continue + for code in class_codes: + class_id = class_code_to_id.get(code) + if not class_id: + results["enrollments"][student_email][code] = "no_class" + continue + r = _api(admin_token, "post", f"/database/timetable/classes/{class_id}/students", { + "student_id": student_pid, + }) + if r.get("status") == "ok" or r.get("id"): + print(f" ✓ {student_email.split('@')[0]} → {code}") + results["enrollments"][student_email][code] = "ok" + else: + err = f"enroll {student_email} → {code}: {r}" + print(f" ✗ {err}") + errors.append(err) + results["enrollments"][student_email][code] = "error" + time.sleep(0.1) + + # ── [11] Materialize taught lessons ──────────────────────────────────────── + print("\n[9] Materializing taught lessons for each teacher...") + results["materialize"] = {} + for teacher_email in TEACHER_SLOTS: + try: + teacher_token = _sign_in(teacher_email, PWD_TEACHER) + except Exception as e: + err = f"login {teacher_email}: {e}" + print(f" ✗ {err}") + errors.append(err) + continue + r = _api(teacher_token, "post", "/timetable/materialize", None) + if r.get("status") == "ok": + print(f" ✓ {teacher_email}: {r.get('lessons_upserted', '?')} lessons, " + f"{r.get('whiteboard_rooms_created', '?')} rooms") + results["materialize"][teacher_email] = "ok" + else: + err = f"materialize {teacher_email}: {r}" + print(f" ✗ {err}") + errors.append(err) + results["materialize"][teacher_email] = "error" + time.sleep(0.3) + + # ── [12] Neo4j sync (B.10) ──────────────────────────────────────────────── + print("\n[10] Syncing Neo4j TaughtLesson nodes (B.10)...") + r = _api(admin_token, "post", "/timetable/sync-lessons", None) + if r.get("status") == "ok": + print(f" ✓ Neo4j sync: {r.get('taught_lessons')} lessons, " + f"{r.get('teacher_timetables')} timetables, {r.get('slots')} slots") + results["neo4j_sync"] = "ok" + else: + err = f"sync-lessons: {r}" + print(f" ✗ {err}") + errors.append(err) + results["neo4j_sync"] = "error" + + # ── Summary ─────────────────────────────────────────────────────────────── + print("\n" + "=" * 60) + results["success"] = len(errors) == 0 + results["errors"] = errors + if errors: + print(f"COMPLETE with {len(errors)} error(s):") + for e in errors: + print(f" ✗ {e}") + else: + print("COMPLETE — all steps succeeded") + print("=" * 60) + return results