api/run/initialization/seed_greenfield_timetable.py
kcar e66c8ec291
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
t4: consolidate seed scripts, remove demo modes, standardize passwords
2026-05-29 19:51:32 +01:00

494 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
from run.initialization.seed_environment import get_seed_password
GREENFIELD_ADMIN_EMAIL = "admin@greenfieldacademy.test"
def _runtime_context() -> Dict[str, str]:
return {
"supa_url": os.environ["SUPABASE_URL"],
"service_key": os.environ["SERVICE_ROLE_KEY"],
"api_base": os.environ.get("API_BASE_URL", "http://localhost:8000"),
}
# ─── 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:
service_key = _runtime_context()["service_key"]
return {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
def _sign_in(email: str, password: str) -> str:
ctx = _runtime_context()
r = requests.post(
f"{ctx['supa_url']}/auth/v1/token?grant_type=password",
headers={"apikey": ctx["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:
api_base = _runtime_context()["api_base"]
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."""
supa_url = _runtime_context()["supa_url"]
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."""
supa_url = _runtime_context()["supa_url"]
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."""
supa_url = _runtime_context()["supa_url"]
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, get_seed_password("school_admin"))
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, get_seed_password("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, get_seed_password("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