- seed_kevlarai_timetable.py: Mirror Greenfield timetable structure for KevlarAI (8 classes, 2 teachers, 2 students, full slot/materialize/sync pipeline) - seed_planned_lessons.py: 2-3 planned lessons per teacher across both schools (6 plans total, idempotent via title+subject check) - seed_file_cabinets.py: One file cabinet per class with sample documents (14 cabinets, ~28 files, document_artefacts, cabinet_memberships) - seed_curriculum.py: Exam board specifications and exams (AQA, Edexcel, OCR) (6 specs, 12 exam papers, Neo4j curriculum topics per school)
457 lines
21 KiB
Python
457 lines
21 KiB
Python
"""
|
|
seed_kevlarai_timetable.py — Full timetable + class + student seed for KevlarAI school.
|
|
|
|
Mirrors Greenfield's structure so both schools are testable.
|
|
KevlarAI gets 8 classes across 3 subjects (Physics, Maths, Computer Science),
|
|
2 teachers, and 2 students.
|
|
|
|
Flow:
|
|
1. POST /timetable/setup — academic year, 3 terms, periods → Supabase
|
|
2. POST /timetable/materialize-periods — academic_periods rows (days x template)
|
|
3. Create classes — 8 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→Yr10, student2→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_kevlarai_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")
|
|
|
|
KEVLARAI_ADMIN_EMAIL = "admin@kevlarai.test"
|
|
KEVLARAI_ADMIN_PWD = "Admin@Cc2025!"
|
|
PWD_TEACHER = "Teacher@Cc2025!"
|
|
PWD_STUDENT = "Student@Cc2025!"
|
|
|
|
# ─── Period templates (same as Greenfield) ─────────────────────────────────────
|
|
|
|
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 ─────────────────────────────────────────────────────────
|
|
# KevlarAI: 8 classes across Physics, Maths, Computer Science
|
|
|
|
CLASSES = [
|
|
# Physics
|
|
{"class_code": "10K/Ph1", "name": "Year 10 Physics Group 1", "subject": "Physics", "year_group": "10", "key_stage": "4", "teacher": "physics@kevlarai.test"},
|
|
{"class_code": "11K/Ph1", "name": "Year 11 Physics Group 1", "subject": "Physics", "year_group": "11", "key_stage": "4", "teacher": "physics@kevlarai.test"},
|
|
# Maths
|
|
{"class_code": "10K/Ma1", "name": "Year 10 Maths Group 1", "subject": "Mathematics", "year_group": "10", "key_stage": "4", "teacher": "maths@kevlarai.test"},
|
|
{"class_code": "11K/Ma1", "name": "Year 11 Maths Group 1", "subject": "Mathematics", "year_group": "11", "key_stage": "4", "teacher": "maths@kevlarai.test"},
|
|
# Computer Science
|
|
{"class_code": "10K/CS1", "name": "Year 10 CS Group 1", "subject": "Computer Science", "year_group": "10", "key_stage": "4", "teacher": "physics@kevlarai.test"},
|
|
{"class_code": "11K/CS1", "name": "Year 11 CS Group 1", "subject": "Computer Science", "year_group": "11", "key_stage": "4", "teacher": "maths@kevlarai.test"},
|
|
# Additional KS3 classes for breadth
|
|
{"class_code": "9K/Ph1", "name": "Year 9 Physics Group 1", "subject": "Physics", "year_group": "9", "key_stage": "3", "teacher": "physics@kevlarai.test"},
|
|
{"class_code": "9K/Ma1", "name": "Year 9 Maths Group 1", "subject": "Mathematics", "year_group": "9", "key_stage": "3", "teacher": "maths@kevlarai.test"},
|
|
]
|
|
|
|
# ─── Teacher slot assignments ──────────────────────────────────────────────────
|
|
|
|
TEACHER_SLOTS = {
|
|
"physics@kevlarai.test": [
|
|
("Monday", "P1", "10K/Ph1"),
|
|
("Monday", "P3", "11K/Ph1"),
|
|
("Tuesday", "P2", "10K/CS1"),
|
|
("Tuesday", "P4", "9K/Ph1"),
|
|
("Wednesday", "P1", "11K/Ph1"),
|
|
("Wednesday", "P5", "10K/Ph1"),
|
|
("Thursday", "P3", "10K/CS1"),
|
|
("Thursday", "P5", "9K/Ph1"),
|
|
],
|
|
"maths@kevlarai.test": [
|
|
("Monday", "P2", "10K/Ma1"),
|
|
("Monday", "P4", "11K/Ma1"),
|
|
("Tuesday", "P1", "9K/Ma1"),
|
|
("Tuesday", "P3", "10K/Ma1"),
|
|
("Wednesday", "P2", "11K/Ma1"),
|
|
("Wednesday", "P4", "9K/Ma1"),
|
|
("Thursday", "P1", "10K/Ma1"),
|
|
("Thursday", "P4", "11K/Ma1"),
|
|
("Friday", "P1", "9K/Ma1"),
|
|
("Friday", "P3", "10K/Ma1"),
|
|
],
|
|
}
|
|
|
|
# ─── Student enrollments ───────────────────────────────────────────────────────
|
|
|
|
STUDENT_ENROLLMENTS = {
|
|
"student1@kevlarai.test": ["10K/Ph1", "10K/Ma1", "10K/CS1"],
|
|
"student2@kevlarai.test": ["11K/Ph1", "11K/Ma1"],
|
|
}
|
|
|
|
|
|
# ─── 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: Optional[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("KevlarAI — full timetable + class + student seed")
|
|
print("=" * 60)
|
|
results: Dict[str, Any] = {}
|
|
errors: List[str] = []
|
|
|
|
# ── [1] Sign in as KevlarAI admin ───────────────────────────────────────
|
|
print("\n[1] Signing in as admin@kevlarai.test...")
|
|
try:
|
|
admin_token = _sign_in(KEVLARAI_ADMIN_EMAIL, KEVLARAI_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 x 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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import json
|
|
print(json.dumps(seed(), indent=2, default=str))
|