From ead44522770579e86ac5bcd8b85d04151a725740 Mon Sep 17 00:00:00 2001 From: Kevin Carter Date: Fri, 29 May 2026 20:49:19 +0100 Subject: [PATCH] feat: add P0 seed scripts for timetable, planned lessons, file cabinets, and curriculum - 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) --- run/initialization/seed_curriculum.py | 366 ++++++++++++++ run/initialization/seed_file_cabinets.py | 423 ++++++++++++++++ run/initialization/seed_kevlarai_timetable.py | 456 ++++++++++++++++++ run/initialization/seed_planned_lessons.py | 385 +++++++++++++++ 4 files changed, 1630 insertions(+) create mode 100644 run/initialization/seed_curriculum.py create mode 100644 run/initialization/seed_file_cabinets.py create mode 100644 run/initialization/seed_kevlarai_timetable.py create mode 100644 run/initialization/seed_planned_lessons.py diff --git a/run/initialization/seed_curriculum.py b/run/initialization/seed_curriculum.py new file mode 100644 index 0000000..8a116d1 --- /dev/null +++ b/run/initialization/seed_curriculum.py @@ -0,0 +1,366 @@ +""" +seed_curriculum.py — Create curriculum data: exam board specifications and exams. + +Seeds eb_specifications and eb_exams tables with realistic UK exam board data +(AQA, Edexcel, OCR) for Physics, Maths, and Computer Science across both schools. + +Also seeds curriculum_topics in Neo4j for the school databases. + +Tables: eb_specifications, eb_exams +Neo4j: curriculum topic nodes in school databases + +Run inside ccapi container: + python3 -c "from run.initialization.seed_curriculum import seed; seed()" +""" +import os +import time +import uuid +import requests +from typing import Dict, Any, List, Optional + +SUPA_URL = os.environ["SUPABASE_URL"] +SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"] +API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000") + +# ─── School constants ──────────────────────────────────────────────────────── + +KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648" +GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + +# ─── Exam board specifications ─────────────────────────────────────────────── +# Realistic UK exam board data for the subjects we teach. + +SPECIFICATIONS = [ + # AQA Physics + { + "spec_code": "AQA-PHYS-8201", + "exam_board_code": "AQA", + "award_code": "8201", + "subject_code": "PHYSICS", + "first_teach": "2016", + "spec_ver": "1.3", + "storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8201_spec.pdf", + "doc_type": "pdf", + }, + { + "spec_code": "AQA-PHYS-8203", + "exam_board_code": "AQA", + "award_code": "8203", + "subject_code": "PHYSICS", + "first_teach": "2016", + "spec_ver": "1.3", + "storage_loc": "cc.public.snapshots/curriculum/aqa/physics/8203_spec.pdf", + "doc_type": "pdf", + }, + # Edexcel Maths + { + "spec_code": "EDX-MATH-1MA1", + "exam_board_code": "EDexcel", + "award_code": "1MA1", + "subject_code": "MATHEMATICS", + "first_teach": "2015", + "spec_ver": "2.0", + "storage_loc": "cc.public.snapshots/curriculum/edexcel/maths/1MA1_spec.pdf", + "doc_type": "pdf", + }, + # OCR Maths + { + "spec_code": "OCR-MATH-FMH1", + "exam_board_code": "OCR", + "award_code": "FMH1", + "subject_code": "MATHEMATICS", + "first_teach": "2017", + "spec_ver": "1.1", + "storage_loc": "cc.public.snapshots/curriculum/ocr/maths/FMH1_spec.pdf", + "doc_type": "pdf", + }, + # AQA Computer Science + { + "spec_code": "AQA-COMP-7516", + "exam_board_code": "AQA", + "award_code": "7516", + "subject_code": "COMPUTER SCIENCE", + "first_teach": "2016", + "spec_ver": "1.2", + "storage_loc": "cc.public.snapshots/curriculum/aqa/cs/7516_spec.pdf", + "doc_type": "pdf", + }, + # Edexcel Computer Science + { + "spec_code": "EDX-COMP-X042", + "exam_board_code": "Edexcel", + "award_code": "X042", + "subject_code": "COMPUTER SCIENCE", + "first_teach": "2016", + "spec_ver": "1.0", + "storage_loc": "cc.public.snapshots/curriculum/edexcel/cs/X042_spec.pdf", + "doc_type": "pdf", + }, +] + +# ─── Exam papers ───────────────────────────────────────────────────────────── +# Realistic exam paper references linked to specifications. + +EXAMS = [ + # AQA Physics 8201/1 (Foundation) + {"exam_code": "AQA-PHYS-8201-1-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1", + "tier": "foundation", "session": "June", "type_code": "QP"}, + {"exam_code": "AQA-PHYS-8201-MS-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1", + "tier": "foundation", "session": "June", "type_code": "MS"}, + {"exam_code": "AQA-PHYS-8201-ER-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/1", + "tier": "foundation", "session": "June", "type_code": "ER"}, + + # AQA Physics 8201/2 (Higher) + {"exam_code": "AQA-PHYS-8201-2-23-JUN", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/2", + "tier": "higher", "session": "June", "type_code": "QP"}, + {"exam_code": "AQA-PHYS-8201-MS-23-JUN-H", "spec_code": "AQA-PHYS-8201", "paper_code": "8201/2", + "tier": "higher", "session": "June", "type_code": "MS"}, + + # Edexcel Maths 1MA1/1 (Foundation) + {"exam_code": "EDX-MATH-1MA1-1-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/1F", + "tier": "foundation", "session": "June", "type_code": "QP"}, + {"exam_code": "EDX-MATH-1MA1-MS-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/1F", + "tier": "foundation", "session": "June", "type_code": "MS"}, + + # Edexcel Maths 1MA1/2 (Higher) + {"exam_code": "EDX-MATH-1MA1-2-24-JUN", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/2H", + "tier": "higher", "session": "June", "type_code": "QP"}, + {"exam_code": "EDX-MATH-1MA1-MS-24-JUN-H", "spec_code": "EDX-MATH-1MA1", "paper_code": "1MA1/2H", + "tier": "higher", "session": "June", "type_code": "MS"}, + + # OCR Maths FMH1/1 + {"exam_code": "OCR-MATH-FMH1-1-24-JUN", "spec_code": "OCR-MATH-FMH1", "paper_code": "FMH1/1", + "tier": "higher", "session": "June", "type_code": "QP"}, + {"exam_code": "OCR-MATH-FMH1-MS-24-JUN", "spec_code": "OCR-MATH-FMH1", "paper_code": "FMH1/1", + "tier": "higher", "session": "June", "type_code": "MS"}, + + # AQA CS 7516/1 + {"exam_code": "AQA-COMP-7516-1-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/1", + "tier": None, "session": "June", "type_code": "QP"}, + {"exam_code": "AQA-COMP-7516-MS-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/1", + "tier": None, "session": "June", "type_code": "MS"}, + + # AQA CS 7516/2 + {"exam_code": "AQA-COMP-7516-2-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/2", + "tier": None, "session": "June", "type_code": "QP"}, + {"exam_code": "AQA-COMP-7516-ER-23-JUN", "spec_code": "AQA-COMP-7516", "paper_code": "7516/2", + "tier": None, "session": "June", "type_code": "ER"}, +] + + +# ─── Neo4j curriculum topics ───────────────────────────────────────────────── +# Curriculum topics stored in Neo4j school databases (not Supabase). + +CURRICULUM_TOPICS = { + "Physics": [ + {"topic_code": "PHYS-KS3-01", "title": "Forces", "year_group": "9", "key_stage": "3", + "description": "Contact and non-contact forces, resultant forces, moments"}, + {"topic_code": "PHYS-KS3-02", "title": "Energy", "year_group": "9", "key_stage": "3", + "description": "Energy stores, transfers, conservation, dissipation"}, + {"topic_code": "PHYS-KS3-03", "title": "Waves", "year_group": "9", "key_stage": "3", + "description": "Transverse and longitudinal waves, reflection, refraction, diffraction"}, + {"topic_code": "PHYS-KS4-01", "title": "Electricity", "year_group": "10", "key_stage": "4", + "description": "Circuits, current, potential difference, resistance, power"}, + {"topic_code": "PHYS-KS4-02", "title": "Magnetism and Electromagnetism", "year_group": "10", "key_stage": "4", + "description": "Magnetic fields, electromagnets, motors, generators"}, + {"topic_code": "PHYS-KS4-03", "title": "Atomic Structure", "year_group": "10", "key_stage": "4", + "description": "Atoms, isotopes, radioactivity, half-life"}, + {"topic_code": "PHYS-KS4-04", "title": "Particle Physics", "year_group": "11", "key_stage": "4", + "description": "Standard model, quarks, leptons, bosons"}, + {"topic_code": "PHYS-KS4-05", "title": "Cosmology", "year_group": "11", "key_stage": "4", + "description": "Big Bang, stellar evolution, redshift"}, + ], + "Mathematics": [ + {"topic_code": "MATH-KS3-01", "title": "Number", "year_group": "9", "key_stage": "3", + "description": "Integers, fractions, decimals, percentages, ratio, proportion"}, + {"topic_code": "MATH-KS3-02", "title": "Algebra", "year_group": "9", "key_stage": "3", + "description": "Expressions, equations, inequalities, sequences"}, + {"topic_code": "MATH-KS3-03", "title": "Geometry", "year_group": "9", "key_stage": "3", + "description": "Angles, polygons, circles, transformations, constructions"}, + {"topic_code": "MATH-KS4-01", "title": "Number and Algebra", "year_group": "10", "key_stage": "4", + "description": "Surds, indices, standard form, expanding brackets, factorising"}, + {"topic_code": "MATH-KS4-02", "title": "Graphs and Functions", "year_group": "10", "key_stage": "4", + "description": "Linear, quadratic, cubic graphs, gradients, intercepts"}, + {"topic_code": "MATH-KS4-03", "title": "Statistics and Probability", "year_group": "10", "key_stage": "4", + "description": "Data types, charts, expected frequency, tree diagrams, two-way tables"}, + {"topic_code": "MATH-KS4-04", "title": "Geometry and Measures", "year_group": "10", "key_stage": "4", + "description": "Area, volume, surface area, Pythagoras, trigonometry, bearings"}, + {"topic_code": "MATH-KS4-05", "title": "Simultaneous Equations and Quadratics", "year_group": "11", "key_stage": "4", + "description": "Solving simultaneous equations, completing the square, quadratic formula"}, + ], + "Computer Science": [ + {"topic_code": "CS-KS4-01", "title": "Data Representation", "year_group": "10", "key_stage": "4", + "description": "Binary, hexadecimal, bit operations, compression, encryption"}, + {"topic_code": "CS-KS4-02", "title": "Computer Systems", "year_group": "10", "key_stage": "4", + "description": "CPU architecture, memory, storage, networks, topologies"}, + {"topic_code": "CS-KS4-03", "title": "Algorithms and Programming", "year_group": "10", "key_stage": "4", + "description": "Algorithms, flowcharts, pseudocode, debugging, testing"}, + {"topic_code": "CS-KS4-04", "title": "Data Types and Structures", "year_group": "11", "key_stage": "4", + "description": "Strings, arrays, lists, records, 2D arrays"}, + {"topic_code": "CS-KS4-05", "title": "Boolean Logic and Search", "year_group": "11", "key_stage": "4", + "description": "Boolean operators, linear search, binary search, sorting"}, + ], +} + + +# ─── Helpers ─────────────────────────────────────────────────────────────────── + +def _sb_headers() -> Dict: + return { + "apikey": SERVICE_KEY, + "Authorization": f"Bearer {SERVICE_KEY}", + "Content-Type": "application/json", + } + + +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"] + + +# ─── Main seed ───────────────────────────────────────────────────────────────── + +def seed() -> Dict[str, Any]: + print("=" * 60) + print("Curriculum seed — exam board specs and exams") + print("=" * 60) + results: Dict[str, Any] = {} + errors: List[str] = [] + + # ── [1] Seed eb_specifications ────────────────────────────────────────── + print("\n[1] Seeding exam board specifications...") + specs_created = 0 + specs_skipped = 0 + + for spec in SPECIFICATIONS: + r = requests.post( + f"{SUPA_URL}/rest/v1/eb_specifications", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={ + **spec, + "id": str(uuid.uuid4()), + "doc_details": {}, + "docling_docs": {}, + }, + params={"on_conflict": "spec_code"}, + ) + if r.status_code in (200, 201): + specs_created += 1 + print(f" ✓ {spec['spec_code']} ({spec['exam_board_code']}/{spec['subject_code']})") + elif r.status_code == 409: + specs_skipped += 1 + print(f" ~ SKIP (exists): {spec['spec_code']}") + else: + err = f"spec {spec['spec_code']}: {r.status_code} {r.text[:100]}" + print(f" ✗ {err}") + errors.append(err) + + results["specifications"] = {"created": specs_created, "skipped": specs_skipped} + + # ── [2] Seed eb_exams ─────────────────────────────────────────────────── + print("\n[2] Seeding exam papers...") + exams_created = 0 + exams_skipped = 0 + + for exam in EXAMS: + r = requests.post( + f"{SUPA_URL}/rest/v1/eb_exams", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={ + **exam, + "id": str(uuid.uuid4()), + "doc_details": {}, + "docling_docs": {}, + }, + params={"on_conflict": "exam_code"}, + ) + if r.status_code in (200, 201): + exams_created += 1 + print(f" ✓ {exam['exam_code']} ({exam['type_code']})") + elif r.status_code == 409: + exams_skipped += 1 + print(f" ~ SKIP (exists): {exam['exam_code']}") + else: + err = f"exam {exam['exam_code']}: {r.status_code} {r.text[:100]}" + print(f" ✗ {err}") + errors.append(err) + + results["exams"] = {"created": exams_created, "skipped": exams_skipped} + + # ── [3] Seed Neo4j curriculum topics ──────────────────────────────────── + print("\n[3] Seeding Neo4j curriculum topics...") + try: + from neo4j import GraphDatabase + driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%")) + + topics_created = 0 + topics_skipped = 0 + + for school_id, school_name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]: + db_name = f"cc.institutes.{school_id.replace('-', '')}" + print(f"\n [{school_name}] -> {db_name}") + + with driver.session(database=db_name) as s: + for subject, topics in CURRICULUM_TOPICS.items(): + # Create subject node + s.run( + "MERGE (s:Subject {code: $subject}) " + "SET s.title = $title, s.school_id = $school_id", + subject=subject, title=subject, school_id=school_id, + ) + + for topic in topics: + result = s.run( + "MERGE (t:CurriculumTopic {code: $code}) " + "SET t.title = $title, " + " t.year_group = $year_group, " + " t.key_stage = $key_stage, " + " t.description = $description, " + " t.subject_code = $subject, " + " t.school_id = $school_id " + "MERGE (s:Subject {code: $subject}) " + "MERGE (s)-[:CONTAINS_TOPIC]->(t)", + code=topic["topic_code"], + title=topic["title"], + year_group=topic["year_group"], + key_stage=topic["key_stage"], + description=topic["description"], + subject=subject, + school_id=school_id, + ) + # Check if it was created or matched + topics_created += 1 + + print(f" ✓ {school_name}: {len(CURRICULUM_TOPICS) * len(list(CURRICULUM_TOPICS.values())[0])} topic nodes") + + driver.close() + results["neo4j_topics"] = {"created": topics_created} + + except Exception as e: + err = f"neo4j_topics: {e}" + print(f" ✗ {err}") + errors.append(err) + results["neo4j_topics"] = {"error": str(e)} + + # ── Summary ───────────────────────────────────────────────────────────── + print("\n" + "=" * 60) + results["success"] = len(errors) == 0 + results["errors"] = errors + print(f"COMPLETE — {specs_created} specs, {exams_created} exams, " + f"{results.get('neo4j_topics', {}).get('created', '?')} topics") + if errors: + print(f"Errors ({len(errors)}):") + for e in errors: + print(f" ✗ {e}") + print("=" * 60) + return results + + +if __name__ == "__main__": + import json + print(json.dumps(seed(), indent=2, default=str)) diff --git a/run/initialization/seed_file_cabinets.py b/run/initialization/seed_file_cabinets.py new file mode 100644 index 0000000..a0f921c --- /dev/null +++ b/run/initialization/seed_file_cabinets.py @@ -0,0 +1,423 @@ +""" +seed_file_cabinets.py — Create one file cabinet per class with sample document references. + +Creates file_cabinets, files, and cabinet_memberships rows via Supabase REST API +using the service role key. Also creates document_artefacts entries for sample files. + +Each cabinet is owned by the class's primary teacher and shared with students +in that class via cabinet_memberships. + +Tables: file_cabinets, files, cabinet_memberships, document_artefacts + +Run inside ccapi container: + python3 -c "from run.initialization.seed_file_cabinets import seed; seed()" +""" +import os +import time +import uuid +import requests +from typing import Dict, Any, List, Optional + +SUPA_URL = os.environ["SUPABASE_URL"] +SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"] +API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000") + +# ─── Passwords (standardized from T4) ──────────────────────────────────────── + +PWD_ADMIN = "Admin@Cc2025!" +PWD_TEACHER = "Teacher@Cc2025!" +PWD_STUDENT = "Student@Cc2025!" + + +# ─── Sample file data ──────────────────────────────────────────────────────── +# Each cabinet gets 2-3 sample files with realistic paths. + +SAMPLE_FILES = { + # Physics cabinets + "lesson_plans": [ + {"name": "forces_motion_plan.pdf", "path": "cc.public.snapshots/lesson_plans/forces_motion.pdf", "mime_type": "application/pdf", "size": "245KB"}, + {"name": "electric_circuits_plan.pdf", "path": "cc.public.snapshots/lesson_plans/electric_circuits.pdf", "mime_type": "application/pdf", "size": "312KB"}, + ], + "worksheets": [ + {"name": "worksheet_fma.pdf", "path": "cc.public.snapshots/worksheets/fma_practice.pdf", "mime_type": "application/pdf", "size": "128KB"}, + {"name": "worksheet_resistance.pdf", "path": "cc.public.snapshots/worksheets/resistance_calc.pdf", "mime_type": "application/pdf", "size": "95KB"}, + ], + "presentations": [ + {"name": "intro_forces.pptx", "path": "cc.public.snapshots/presentations/forces_intro.pptx", "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "size": "2.1MB"}, + ], + # Maths cabinets + "lesson_plans": [ + {"name": "quadratic_factorisation_plan.pdf", "path": "cc.public.snapshots/lesson_plans/quadratics.pdf", "mime_type": "application/pdf", "size": "278KB"}, + ], + "worksheets": [ + {"name": "worksheet_quadratics.pdf", "path": "cc.public.snapshots/worksheets/quadratic_practice.pdf", "mime_type": "application/pdf", "size": "156KB"}, + {"name": "worksheet_tree_diagrams.pdf", "path": "cc.public.snapshots/worksheets/tree_diagrams.pdf", "mime_type": "application/pdf", "size": "134KB"}, + ], + # CS cabinets + "lesson_plans": [ + {"name": "intro_python_plan.pdf", "path": "cc.public.snapshots/lesson_plans/intro_python.pdf", "mime_type": "application/pdf", "size": "198KB"}, + ], + "code_samples": [ + {"name": "hello_world.py", "path": "cc.public.snapshots/code_samples/hello_world.py", "mime_type": "text/x-python", "size": "0.5KB"}, + {"name": "variables.py", "path": "cc.public.snapshots/code_samples/variables.py", "mime_type": "text/x-python", "size": "1.2KB"}, + ], +} + + +# ─── Helpers ─────────────────────────────────────────────────────────────────── + +def _sb_headers() -> Dict: + return { + "apikey": SERVICE_KEY, + "Authorization": f"Bearer {SERVICE_KEY}", + "Content-Type": "application/json", + } + + +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 _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_class_info(admin_token: str, class_code: str) -> Optional[Dict]: + """Get class info including teacher and students.""" + r = requests.get( + f"{API_BASE}/database/timetable/classes", + headers={"Authorization": f"Bearer {admin_token}"}, + params={"class_code": class_code}, + ) + if not r.ok: + return None + data = r.json() + if isinstance(data, list) and data: + return data[0] + if isinstance(data, dict): + return data + return None + + +def _get_class_students(admin_token: str, class_id: str) -> List[str]: + """Get student profile IDs enrolled in a class.""" + r = requests.get( + f"{API_BASE}/database/timetable/classes/{class_id}/students", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + if r.ok: + data = r.json() + if isinstance(data, list): + return [s.get("student_id") or s.get("id") for s in data if s.get("student_id") or s.get("id")] + return [] + + +# ─── Main seed ───────────────────────────────────────────────────────────────── + +def seed() -> Dict[str, Any]: + print("=" * 60) + print("File cabinets seed — both schools") + print("=" * 60) + results: Dict[str, Any] = {} + errors: List[str] = [] + + # ── Sign in as both school admins ─────────────────────────────────────── + print("\n[1] Signing in as school admins...") + admin_tokens = {} + for school, email, pwd in [ + ("KevlarAI", "admin@kevlarai.test", PWD_ADMIN), + ("Greenfield", "admin@greenfieldacademy.test", PWD_ADMIN), + ]: + try: + token = _sign_in(email, pwd) + admin_tokens[school] = token + print(f" ✓ {school} admin signed in") + except Exception as e: + print(f" ✗ {school} admin login failed: {e}") + errors.append(f"{school}_admin_login: {e}") + + if not admin_tokens: + return {"success": False, "error": "No admin tokens obtained"} + + # ── Resolve class codes per school ────────────────────────────────────── + print("\n[2] Resolving classes per school...") + + # KevlarAI classes + kevlarai_classes = [ + ("10K/Ph1", "physics@kevlarai.test"), + ("11K/Ph1", "physics@kevlarai.test"), + ("10K/Ma1", "maths@kevlarai.test"), + ("11K/Ma1", "maths@kevlarai.test"), + ("10K/CS1", "physics@kevlarai.test"), + ("11K/CS1", "maths@kevlarai.test"), + ("9K/Ph1", "physics@kevlarai.test"), + ("9K/Ma1", "maths@kevlarai.test"), + ] + + # Greenfield classes (subset — just a few for cabinet seeding) + greenfield_classes = [ + ("9P/Ph1", "physics@greenfieldacademy.test"), + ("10P/Ph2", "physics@greenfieldacademy.test"), + ("9M/Ma1", "maths@greenfieldacademy.test"), + ("10M/Ma1", "maths@greenfieldacademy.test"), + ("9En/1", "teacher1@greenfieldacademy.test"), + ("10Hs/1", "teacher2@greenfieldacademy.test"), + ] + + # ── Seed KevlarAI cabinets ────────────────────────────────────────────── + print("\n[3] Seeding KevlarAI file cabinets...") + results["kevlarai"] = {"cabinets": 0, "files": 0, "memberships": 0} + + for class_code, teacher_email in kevlarai_classes: + try: + # Get class info + class_info = _get_class_info(admin_tokens["KevlarAI"], class_code) + if not class_info: + print(f" ✗ class not found: {class_code}") + errors.append(f"class_not_found: {class_code}") + continue + + class_id = class_info.get("id") or class_info + teacher_pid = _get_profile_id(teacher_email) + if not teacher_pid: + print(f" ✗ teacher profile not found: {teacher_email}") + errors.append(f"teacher_profile_not_found: {teacher_email}") + continue + + # Get students in this class + student_ids = _get_class_students(admin_tokens["KevlarAI"], str(class_id)) + + # Determine file category based on subject + subject = (class_info.get("subject") or "").lower() + if "physics" in subject: + file_category = "lesson_plans" + elif "math" in subject: + file_category = "worksheets" + elif "cs" in subject or "computer" in subject: + file_category = "code_samples" + else: + file_category = "lesson_plans" + + files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"]) + + # Create cabinet + cabinet_id = str(uuid.uuid4()) + cabinet_name = f"{class_code} — {class_info.get('name', class_code)}" + + r = requests.post( + f"{SUPA_URL}/rest/v1/file_cabinets", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name}, + params={"on_conflict": "id"}, + ) + if r.status_code in (200, 201): + print(f" ✓ Cabinet: {cabinet_name}") + results["kevlarai"]["cabinets"] += 1 + else: + print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}") + errors.append(f"cabinet_create: {class_code}") + continue + + # Create files in cabinet + for fi in files_list: + file_id = str(uuid.uuid4()) + r = requests.post( + f"{SUPA_URL}/rest/v1/files", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={ + "id": file_id, + "cabinet_id": cabinet_id, + "name": fi["name"], + "path": fi["path"], + "bucket": "file-cabinets", + "mime_type": fi.get("mime_type"), + "size": fi.get("size"), + "metadata": {}, + }, + params={"on_conflict": "id"}, + ) + if r.status_code in (200, 201): + results["kevlarai"]["files"] += 1 + + # Create document_artefact for this file + artefact_id = str(uuid.uuid4()) + requests.post( + f"{SUPA_URL}/rest/v1/document_artefacts", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={ + "id": artefact_id, + "file_id": file_id, + "type": fi.get("mime_type", "application/octet-stream").split("/")[-1], + "rel_path": fi["path"], + "status": "processed", + "extra": {"seeded": True, "source": "seed_file_cabinets"}, + }, + params={"on_conflict": "id"}, + ) + + time.sleep(0.05) + + # Create cabinet memberships for students + for sid in student_ids: + r = requests.post( + f"{SUPA_URL}/rest/v1/cabinet_memberships", + headers={**_sb_headers(), "Prefer": "return=minimal"}, + json={ + "cabinet_id": cabinet_id, + "profile_id": sid, + "role": "viewer", + }, + params={"on_conflict": "cabinet_id,profile_id"}, + ) + if r.status_code in (200, 201, 409): + results["kevlarai"]["memberships"] += 1 + + time.sleep(0.1) + + except Exception as e: + err = f"cabinet seed {class_code}: {e}" + print(f" ✗ {err}") + errors.append(err) + + # ── Seed Greenfield cabinets ──────────────────────────────────────────── + print("\n[4] Seeding Greenfield file cabinets...") + results["greenfield"] = {"cabinets": 0, "files": 0, "memberships": 0} + + for class_code, teacher_email in greenfield_classes: + try: + class_info = _get_class_info(admin_tokens["Greenfield"], class_code) + if not class_info: + print(f" ✗ class not found: {class_code}") + errors.append(f"class_not_found: {class_code}") + continue + + class_id = class_info.get("id") or class_info + teacher_pid = _get_profile_id(teacher_email) + if not teacher_pid: + print(f" ✗ teacher profile not found: {teacher_email}") + errors.append(f"teacher_profile_not_found: {teacher_email}") + continue + + student_ids = _get_class_students(admin_tokens["Greenfield"], str(class_id)) + + subject = (class_info.get("subject") or "").lower() + if "physics" in subject: + file_category = "lesson_plans" + elif "math" in subject: + file_category = "worksheets" + elif "english" in subject: + file_category = "presentations" + elif "history" in subject: + file_category = "lesson_plans" + else: + file_category = "lesson_plans" + + files_list = SAMPLE_FILES.get(file_category, SAMPLE_FILES["lesson_plans"]) + + cabinet_id = str(uuid.uuid4()) + cabinet_name = f"{class_code} — {class_info.get('name', class_code)}" + + r = requests.post( + f"{SUPA_URL}/rest/v1/file_cabinets", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={"id": cabinet_id, "user_id": teacher_pid, "name": cabinet_name}, + params={"on_conflict": "id"}, + ) + if r.status_code in (200, 201): + print(f" ✓ Cabinet: {cabinet_name}") + results["greenfield"]["cabinets"] += 1 + else: + print(f" ✗ Cabinet create failed ({class_code}): {r.text[:100]}") + errors.append(f"cabinet_create: {class_code}") + continue + + for fi in files_list: + file_id = str(uuid.uuid4()) + r = requests.post( + f"{SUPA_URL}/rest/v1/files", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={ + "id": file_id, + "cabinet_id": cabinet_id, + "name": fi["name"], + "path": fi["path"], + "bucket": "file-cabinets", + "mime_type": fi.get("mime_type"), + "size": fi.get("size"), + "metadata": {}, + }, + params={"on_conflict": "id"}, + ) + if r.status_code in (200, 201): + results["greenfield"]["files"] += 1 + + artefact_id = str(uuid.uuid4()) + requests.post( + f"{SUPA_URL}/rest/v1/document_artefacts", + headers={**_sb_headers(), "Prefer": "return=representation"}, + json={ + "id": artefact_id, + "file_id": file_id, + "type": fi.get("mime_type", "application/octet-stream").split("/")[-1], + "rel_path": fi["path"], + "status": "processed", + "extra": {"seeded": True, "source": "seed_file_cabinets"}, + }, + params={"on_conflict": "id"}, + ) + + time.sleep(0.05) + + for sid in student_ids: + r = requests.post( + f"{SUPA_URL}/rest/v1/cabinet_memberships", + headers={**_sb_headers(), "Prefer": "return=minimal"}, + json={ + "cabinet_id": cabinet_id, + "profile_id": sid, + "role": "viewer", + }, + params={"on_conflict": "cabinet_id,profile_id"}, + ) + if r.status_code in (200, 201, 409): + results["greenfield"]["memberships"] += 1 + + time.sleep(0.1) + + except Exception as e: + err = f"cabinet seed {class_code}: {e}" + print(f" ✗ {err}") + errors.append(err) + + # ── Summary ───────────────────────────────────────────────────────────── + print("\n" + "=" * 60) + results["success"] = len(errors) == 0 + results["errors"] = errors + total_cabinets = results["kevlarai"]["cabinets"] + results["greenfield"]["cabinets"] + total_files = results["kevlarai"]["files"] + results["greenfield"]["files"] + total_memberships = results["kevlarai"]["memberships"] + results["greenfield"]["memberships"] + print(f"COMPLETE — {total_cabinets} cabinets, {total_files} files, {total_memberships} memberships") + if errors: + print(f"Errors ({len(errors)}):") + for e in errors: + print(f" ✗ {e}") + print("=" * 60) + return results + + +if __name__ == "__main__": + import json + print(json.dumps(seed(), indent=2, default=str)) diff --git a/run/initialization/seed_kevlarai_timetable.py b/run/initialization/seed_kevlarai_timetable.py new file mode 100644 index 0000000..f44bb20 --- /dev/null +++ b/run/initialization/seed_kevlarai_timetable.py @@ -0,0 +1,456 @@ +""" +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)) diff --git a/run/initialization/seed_planned_lessons.py b/run/initialization/seed_planned_lessons.py new file mode 100644 index 0000000..1c5f1a3 --- /dev/null +++ b/run/initialization/seed_planned_lessons.py @@ -0,0 +1,385 @@ +""" +seed_planned_lessons.py — Create 2-3 planned lessons per teacher across both schools. + +Uses the /lessons/plans API endpoint (POST) to create lesson plans. +Each plan is linked to a class, subject, and year group where possible. +Plans are idempotent: checks for existing plans by title+subject before creating. + +Tables: planned_lessons, lesson_collaborators, lesson_deliveries + +Run inside ccapi container: + python3 -c "from run.initialization.seed_planned_lessons import seed; seed()" +""" +import os +import time +import requests +from typing import Dict, Any, List, Optional + +SUPA_URL = os.environ["SUPABASE_URL"] +SERVICE_KEY = os.environ["SERVICE_ROLE_KEY"] +API_BASE = os.environ.get("API_BASE_URL", "http://localhost:8000") + +# ─── Passwords (standardized from T4) ──────────────────────────────────────── + +PWD_ADMIN = "Admin@Cc2025!" +PWD_TEACHER = "Teacher@Cc2025!" + +# ─── Planned lesson templates per school ────────────────────────────────────── +# Each entry: (teacher_email, title, subject, year_group, class_code, objectives, activities) + +KEVLARAI_PLANS = [ + { + "teacher": "physics@kevlarai.test", + "title": "Introduction to Forces and Motion", + "subject": "Physics", + "year_group": "10", + "class_code": "10K/Ph1", + "objectives": [ + {"text": "Define force, mass, and acceleration", "bloom": "remember"}, + {"text": "Apply F=ma to solve simple problems", "bloom": "apply"}, + ], + "activities": [ + {"type": "demo", "description": "Demonstrate forces with spring scales"}, + {"type": "worksheet", "description": "F=ma calculation practice (10 problems)"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "physics@kevlarai.test", + "title": "Electric Circuits Basics", + "subject": "Physics", + "year_group": "11", + "class_code": "11K/Ph1", + "objectives": [ + {"text": "Identify series and parallel circuit components", "bloom": "understand"}, + {"text": "Calculate total resistance in series circuits", "bloom": "apply"}, + ], + "activities": [ + {"type": "lab", "description": "Build series circuit with resistors"}, + {"type": "quiz", "description": "Resistance calculation quiz (5 questions)"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "maths@kevlarai.test", + "title": "Quadratic Equations — Factorisation Method", + "subject": "Mathematics", + "year_group": "10", + "class_code": "10K/Ma1", + "objectives": [ + {"text": "Factorise quadratic expressions of the form x²+bx+c", "bloom": "apply"}, + {"text": "Solve quadratic equations by factorisation", "bloom": "analyse"}, + ], + "activities": [ + {"type": "direct_instruction", "description": "Walk through 3 worked examples"}, + {"type": "pair_work", "description": "Factorise 8 quadratics with a partner"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "maths@kevlarai.test", + "title": "Probability — Tree Diagrams", + "subject": "Mathematics", + "year_group": "11", + "class_code": "11K/Ma1", + "objectives": [ + {"text": "Construct tree diagrams for two-stage events", "bloom": "apply"}, + {"text": "Calculate combined probabilities from tree diagrams", "bloom": "analyse"}, + ], + "activities": [ + {"type": "demo", "description": "Coin toss tree diagram on whiteboard"}, + {"type": "worksheet", "description": "5 tree diagram probability problems"}, + ], + "duration_minutes": 60, + }, +] + +GREENFIELD_PLANS = [ + { + "teacher": "physics@greenfieldacademy.test", + "title": "Waves and Sound", + "subject": "Physics", + "year_group": "9", + "class_code": "9P/Ph1", + "objectives": [ + {"text": "Describe properties of transverse and longitudinal waves", "bloom": "remember"}, + {"text": "Calculate wave speed using v=fλ", "bloom": "apply"}, + ], + "activities": [ + {"type": "demo", "description": "Slinky wave demonstrations"}, + {"type": "worksheet", "description": "Wave speed calculations (8 problems)"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "physics@greenfieldacademy.test", + "title": "Energy Transfers and Conservation", + "subject": "Physics", + "year_group": "10", + "class_code": "10P/Ph2", + "objectives": [ + {"text": "Identify energy stores and transfer pathways", "bloom": "understand"}, + {"text": "Apply conservation of energy to real-world scenarios", "bloom": "analyse"}, + ], + "activities": [ + {"type": "group_work", "description": "Energy audit of a household"}, + {"type": "presentation", "description": "Present findings on energy efficiency"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "maths@greenfieldacademy.test", + "title": "Algebra — Expanding Brackets", + "subject": "Mathematics", + "year_group": "9", + "class_code": "9M/Ma1", + "objectives": [ + {"text": "Expand single brackets: a(b+c)", "bloom": "apply"}, + {"text": "Expand double brackets: (a+b)(c+d)", "bloom": "analyse"}, + ], + "activities": [ + {"type": "direct_instruction", "description": "Area model for expanding brackets"}, + {"type": "worksheet", "description": "15 expansion problems (graded difficulty)"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "maths@greenfieldacademy.test", + "title": "Simultaneous Equations — Elimination Method", + "subject": "Mathematics", + "year_group": "10", + "class_code": "10M/Ma1", + "objectives": [ + {"text": "Solve simultaneous equations by elimination", "bloom": "apply"}, + {"text": "Choose between substitution and elimination strategically", "bloom": "evaluate"}, + ], + "activities": [ + {"type": "direct_instruction", "description": "Walk through 3 elimination examples"}, + {"type": "pair_work", "description": "Solve 6 simultaneous equation pairs"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "teacher1@greenfieldacademy.test", + "title": "Shakespeare — Macbeth Act 1 Analysis", + "subject": "English", + "year_group": "9", + "class_code": "9En/1", + "objectives": [ + {"text": "Identify key themes in Act 1", "bloom": "understand"}, + {"text": "Analyse Shakespeare's use of imagery and language", "bloom": "analyse"}, + ], + "activities": [ + {"type": "close_reading", "description": "Close read Act 1, Scene 3 (witches' prophecy)"}, + {"type": "essay", "description": "Short paragraph: How does Shakespeare create tension?"}, + ], + "duration_minutes": 60, + }, + { + "teacher": "teacher2@greenfieldacademy.test", + "title": "The Tudors — Henry VIII's Reforms", + "subject": "History", + "year_group": "10", + "class_code": "10Hs/1", + "objectives": [ + {"text": "Describe Henry VIII's religious reforms", "bloom": "remember"}, + {"text": "Evaluate the political motivations behind the reforms", "bloom": "evaluate"}, + ], + "activities": [ + {"type": "source_analysis", "description": "Analyze Act of Supremacy 1534"}, + {"type": "debate", "description": "Was Henry's break with Rome politically necessary?"}, + ], + "duration_minutes": 60, + }, +] + + +# ─── Helpers ─────────────────────────────────────────────────────────────────── + +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={ + "apikey": SERVICE_KEY, + "Authorization": f"Bearer {SERVICE_KEY}", + "Content-Type": "application/json", + }, + 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_class_id(class_code: str, admin_token: str) -> Optional[str]: + """Look up a class UUID by class_code via the API.""" + r = requests.get( + f"{API_BASE}/database/timetable/classes", + headers={"Authorization": f"Bearer {admin_token}"}, + params={"class_code": class_code}, + ) + data = r.json() if r.ok else [] + if isinstance(data, list) and data: + return data[0].get("id") or data[0] + if isinstance(data, dict): + return data.get("id") or data.get("class", {}).get("id") + return None + + +def _existing_plans_for_teacher(token: str, teacher_email: str) -> List[str]: + """Return list of existing plan titles for a teacher (to check idempotency).""" + r = requests.get( + f"{API_BASE}/lessons/plans", + headers={"Authorization": f"Bearer {token}"}, + ) + if r.ok: + plans = r.json().get("plans", []) + return [p.get("title", "") for p in plans] + return [] + + +# ─── Main seed ───────────────────────────────────────────────────────────────── + +def seed() -> Dict[str, Any]: + print("=" * 60) + print("Planned lessons seed — both schools") + print("=" * 60) + results: Dict[str, Any] = {} + errors: List[str] = [] + + # ── Sign in as both school admins ─────────────────────────────────────── + print("\n[1] Signing in as school admins...") + admin_tokens = {} + for school, email, pwd in [ + ("KevlarAI", "admin@kevlarai.test", PWD_ADMIN), + ("Greenfield", "admin@greenfieldacademy.test", PWD_ADMIN), + ]: + try: + token = _sign_in(email, pwd) + admin_tokens[school] = token + print(f" ✓ {school} admin signed in") + except Exception as e: + print(f" ✗ {school} admin login failed: {e}") + errors.append(f"{school}_admin_login: {e}") + + if not admin_tokens: + return {"success": False, "error": "No admin tokens obtained"} + + # ── Resolve class IDs ─────────────────────────────────────────────────── + print("\n[2] Resolving class IDs...") + all_class_codes = set() + for plans in [KEVLARAI_PLANS, GREENFIELD_PLANS]: + for p in plans: + if p.get("class_code"): + all_class_codes.add(p["class_code"]) + + class_code_to_id: Dict[str, str] = {} + for code in all_class_codes: + # Try KevlarAI first, then Greenfield + for school in ["KevlarAI", "Greenfield"]: + cid = _get_class_id(code, admin_tokens[school]) + if cid: + class_code_to_id[code] = cid + print(f" ✓ {code} -> {cid[:8]}...") + break + else: + print(f" ✗ class not found: {code}") + errors.append(f"class_not_found: {code}") + + # ── Seed planned lessons ──────────────────────────────────────────────── + print("\n[3] Creating planned lessons...") + created_count = 0 + skipped_count = 0 + + for school, plans in [("KevlarAI", KEVLARAI_PLANS), ("Greenfield", GREENFIELD_PLANS)]: + admin_token = admin_tokens[school] + print(f"\n [{school}]") + + for plan_spec in plans: + teacher_email = plan_spec["teacher"] + title = plan_spec["title"] + + # Check idempotency + 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 + + existing = _existing_plans_for_teacher(teacher_token, teacher_email) + if title in existing: + print(f" ~ SKIP (exists): {title}") + skipped_count += 1 + continue + + # Build class_id + class_id = None + cc = plan_spec.get("class_code") + if cc: + class_id = class_code_to_id.get(cc) + + body = { + "title": title, + "subject": plan_spec["subject"], + "year_group": plan_spec["year_group"], + "estimated_duration_minutes": plan_spec.get("duration_minutes", 60), + "objectives": plan_spec["objectives"], + "activities": plan_spec["activities"], + "status": "draft", + "tags": [plan_spec["subject"].lower(), f"yr{plan_spec['year_group']}"], + } + if class_id: + body["class_id"] = class_id + + r = _api(teacher_token, "post", "/lessons/plans", body) + plan_id = r.get("id") or (r.get("planned_lesson", {}) or {}).get("id") + if plan_id: + print(f" ✓ {title} [{plan_id[:8]}...]") + created_count += 1 + else: + err = f"create plan '{title}': {r}" + print(f" ✗ {err}") + errors.append(err) + + time.sleep(0.2) + + # ── Summary ───────────────────────────────────────────────────────────── + print("\n" + "=" * 60) + results["success"] = len(errors) == 0 + results["errors"] = errors + results["created"] = created_count + results["skipped"] = skipped_count + if errors: + print(f"COMPLETE with {len(errors)} error(s):") + for e in errors: + print(f" ✗ {e}") + else: + print(f"COMPLETE — {created_count} created, {skipped_count} skipped (idempotent)") + print("=" * 60) + return results + + +if __name__ == "__main__": + import json + print(json.dumps(seed(), indent=2, default=str))