Compare commits

..

No commits in common. "922d4d5a8d50a2366e26e2a2c03f6494c71d3238" and "74b69e9f3c97cea9000bb7974f5c5db950b2d32a" have entirely different histories.

4 changed files with 100 additions and 669 deletions

View File

@ -146,15 +146,3 @@ async def seed_environment(
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _seed)
return {"status": "ok", **result}
@router.post("/seed-timetable")
async def seed_greenfield_timetable(
_: dict = Depends(require_platform_admin),
) -> Dict[str, Any]:
"""Seed full timetable + taught lessons for Greenfield Academy. Platform admin only."""
import asyncio
from run.initialization.seed_greenfield_timetable import seed as _seed
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, _seed)
return {"status": "ok", **result}

View File

@ -2,14 +2,15 @@
reset_environment.py DESTRUCTIVE wipe of all non-permanent data.
Clears:
- Neo4j: drops ALL databases except system, neo4j (including gaisdata, cc.users.*, cc.institutes.*)
- Supabase: deletes ALL data tables except gais_local_authorities and gais_schools
- Supabase: deletes all auth users except kcar, then re-seeds kcar profile state
- Neo4j: drops cc.users.*, classroomcopilot; wipes cc.institutes.* content
- Supabase: deletes all test auth users + profiles + memberships
- Supabase: detaches kcar from any school
Safe invariants (never touched):
- kcar auth account
- gais_local_authorities and gais_schools Supabase tables
- system / neo4j Neo4j system databases
- gaisdata Neo4j DB
- system / neo4j Neo4j DBs
- kcar auth account and admin_profiles entry
- institutes rows (schools themselves are kept, just de-seeded)
Run from inside the ccapi container:
python3 -c "from run.initialization.reset_environment import reset; reset()"
@ -20,6 +21,7 @@ import requests
from typing import List, Dict, Any
from modules.logger_tool import initialise_logger
from modules.database.services.neo4j_service import Neo4jService
import modules.database.tools.neo4j_driver_tools as dt
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
@ -27,111 +29,59 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
KCAR_EMAIL = "kcar@kevlarai.com"
# Neo4j system databases — never drop these
NEO4J_SYSTEM_DBS = {"system", "neo4j"}
# Supabase tables to clear, in FK child-first order.
# gais_local_authorities and gais_schools are intentionally absent.
SUPABASE_TABLES_TO_CLEAR = [
# ── Transcription (deepest children first) ───────────────────────────────
"canvas_events",
"keyword_events",
"transcription_summaries",
"transcription_segments",
"keyword_watches",
"transcription_sessions",
# ── Lesson delivery chain ────────────────────────────────────────────────
"lesson_deliveries",
"lesson_collaborators",
# ── Timetable materialization ────────────────────────────────────────────
"taught_lessons",
# ── Academic calendar (children → parents) ───────────────────────────────
"academic_periods",
"academic_days",
"academic_weeks",
"academic_term_breaks",
"academic_terms",
"academic_years",
# ── Teacher timetables ───────────────────────────────────────────────────
"teacher_timetable_slots",
"teacher_timetables",
"school_timetables",
# ── Lesson plans ─────────────────────────────────────────────────────────
"planned_lessons",
# ── Whiteboard rooms ─────────────────────────────────────────────────────
"whiteboard_rooms",
# ── Classes & enrollment ─────────────────────────────────────────────────
"enrollment_requests",
"class_students",
"class_teachers",
"classes",
# ── Files & brains ───────────────────────────────────────────────────────
"document_artefacts",
"brain_files",
"cabinet_memberships",
"files",
"file_cabinets",
"brains",
# ── Invitations & memberships ────────────────────────────────────────────
"invitations",
"institute_memberships",
"institute_membership_requests",
# ── Institutes ───────────────────────────────────────────────────────────
"institutes",
# ── Profiles (non-kcar cleared separately via auth deletion cascade) ─────
"admin_profiles",
# Databases to fully DROP (content + structure)
DBS_TO_DROP = [
"classroomcopilot",
"cc.users",
]
# Institute DBs — wipe content only (keep the DB, re-provision in seed)
INSTITUTE_DB_PREFIXES = ["cc.institutes."]
# Supabase connection details (direct REST, no SDK needed for admin auth ops)
def _sb_headers():
url = os.environ["SUPABASE_URL"]
key = os.environ["SERVICE_ROLE_KEY"]
return url, {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Prefer": "return=minimal",
}
return url, {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
# ─── Neo4j helpers ────────────────────────────────────────────────────────────
def _neo4j_drop_all_non_system() -> Dict[str, List[str]]:
"""Drop every Neo4j DB except the system-reserved ones."""
def _neo4j_drop_all_matching(pattern: str) -> List[str]:
"""Drop every Neo4j database whose name starts with pattern."""
dropped = []
with dt.get_session(database="system") as s:
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
to_drop = [db for db in all_dbs if db not in NEO4J_SYSTEM_DBS]
dropped = []
for db in to_drop:
targets = [db for db in all_dbs if db.startswith(pattern)]
for db in targets:
logger.info(f" DROP DATABASE `{db}`")
try:
with dt.get_session(database="system") as s:
s.run(f"DROP DATABASE `{db}` IF EXISTS")
dropped.append(db)
except Exception as e:
logger.warning(f" Could not drop `{db}`: {e}")
return dropped
# ─── Supabase helpers ─────────────────────────────────────────────────────────
# Tables without an uid=1000(kcar) gid=1000(kcar) groups=1000(kcar),27(sudo),119(docker) column — map to the column to use as the delete filter.
TABLE_FILTER_COLUMN = {
"brain_files": "brain_id",
}
def _sb_clear_table(url: str, headers: dict, table: str) -> int:
"""Delete all rows from a Supabase table. Returns HTTP status."""
col = TABLE_FILTER_COLUMN.get(table, "id")
r = requests.delete(
f"{url}/rest/v1/{table}",
headers=headers,
params={col: "not.is.null"},
)
if r.status_code not in (200, 204):
logger.warning(f" Clear {table}: {r.status_code} {r.text[:120]}")
return r.status_code
def _neo4j_wipe_institute_dbs() -> List[str]:
"""MATCH (n) DETACH DELETE on every cc.institutes.* database."""
wiped = []
with dt.get_session(database="system") as s:
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
targets = [db for db in all_dbs
if any(db.startswith(p) for p in INSTITUTE_DB_PREFIXES)
and not db.endswith(".curriculum")]
for db in targets:
logger.info(f" Wipe cc.institutes DB: {db}")
with dt.get_session(database=db) as s:
s.run("MATCH (n) DETACH DELETE n")
wiped.append(db)
# Also wipe curriculum DBs
with dt.get_session(database="system") as s:
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
curriculum_dbs = [db for db in all_dbs if db.endswith(".curriculum")]
for db in curriculum_dbs:
logger.info(f" Wipe curriculum DB: {db}")
with dt.get_session(database=db) as s:
s.run("MATCH (n) DETACH DELETE n")
wiped.append(db)
return wiped
def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]:
@ -143,38 +93,49 @@ def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]:
def _supabase_delete_auth_user(url: str, headers: dict, uid: str):
r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers)
if r.status_code not in (200, 204):
logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:80]}")
logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:100]}")
# ─── Main reset ───────────────────────────────────────────────────────────────
def reset() -> Dict[str, Any]:
logger.info("=" * 60)
logger.info("RESET ENVIRONMENT — full destructive wipe starting")
logger.info("RESET ENVIRONMENT — destructive wipe starting")
logger.info("=" * 60)
results: Dict[str, Any] = {}
# ── 1. Neo4j: drop everything except system + neo4j ──────────────────────
logger.info("\n[Neo4j] Dropping all non-system databases...")
dropped = _neo4j_drop_all_non_system()
logger.info(f" Dropped {len(dropped)}: {dropped}")
results["neo4j"] = {"dropped": dropped}
# ── 1. Neo4j: drop cc.users.* and classroomcopilot ───────────────────────
logger.info("\n[Neo4j] Dropping cc.users.* databases...")
dropped = _neo4j_drop_all_matching("cc.users")
logger.info(f" Dropped: {dropped}")
# ── 2. Supabase: clear all data tables (GAIS preserved) ──────────────────
logger.info("\n[Supabase] Clearing data tables (preserving gais_*)...")
logger.info("[Neo4j] Dropping classroomcopilot...")
with dt.get_session(database="system") as s:
s.run("DROP DATABASE `classroomcopilot` IF EXISTS")
logger.info(" Done")
# ── 2. Neo4j: wipe institute DB content ──────────────────────────────────
logger.info("[Neo4j] Wiping cc.institutes.* content...")
wiped = _neo4j_wipe_institute_dbs()
logger.info(f" Wiped: {wiped}")
results["neo4j"] = {"dropped": dropped, "wiped": wiped}
# ── 3. Supabase: detach kcar from school ──────────────────────────────────
logger.info("\n[Supabase] Detaching kcar from school...")
url, headers = _sb_headers()
cleared, failed = [], []
for table in SUPABASE_TABLES_TO_CLEAR:
status = _sb_clear_table(url, headers, table)
if status in (200, 204):
cleared.append(table)
logger.info(f"{table}")
else:
failed.append(table)
logger.info(f" Cleared {len(cleared)} tables, {len(failed)} failed")
requests.patch(
f"{url}/rest/v1/profiles",
headers={**headers, "Prefer": "return=minimal"},
params={"id": f"eq.{KCAR_ID}"},
json={"school_id": None},
)
requests.delete(
f"{url}/rest/v1/institute_memberships",
headers=headers,
params={"profile_id": f"eq.{KCAR_ID}"},
)
logger.info(" kcar detached")
# ── 3. Supabase: delete all auth users except kcar ────────────────────────
logger.info("\n[Supabase] Deleting test auth users...")
# ── 4. Supabase: delete all test users except kcar ────────────────────────
logger.info("[Supabase] Deleting test auth users...")
all_users = _supabase_list_auth_users(url, headers)
deleted_emails = []
for u in all_users:
@ -182,42 +143,23 @@ def reset() -> Dict[str, Any]:
continue
_supabase_delete_auth_user(url, headers, u["id"])
deleted_emails.append(u["email"])
time.sleep(0.05)
time.sleep(0.1)
logger.info(f" Deleted {len(deleted_emails)} auth users")
# Explicit cleanup in case cascade didn't fire
requests.delete(f"{url}/rest/v1/profiles", headers=headers,
params={"id": f"neq.{KCAR_ID}"})
# ── 4. Reset kcar profile to known-good platform_admin state ──────────────
logger.info("\n[Supabase] Resetting kcar profile...")
requests.patch(
# profiles + memberships cascade via FK on auth.users deletion (Supabase handles it)
# but clean up explicitly to be safe
requests.delete(
f"{url}/rest/v1/profiles",
headers=headers,
params={"id": f"eq.{KCAR_ID}"},
json={"school_id": None},
params={"id": f"neq.{KCAR_ID}"},
)
logger.info(" kcar → school_id: null ✓")
# Restore admin_profiles row (wiped with other tables above)
requests.post(
f"{url}/rest/v1/admin_profiles",
headers={**headers, "Prefer": "resolution=merge-duplicates"},
json={
"id": KCAR_ID,
"email": KCAR_EMAIL,
"display_name": "Kevin Carroll",
"admin_role": "super_admin",
"is_super_admin": True,
},
requests.delete(
f"{url}/rest/v1/institute_memberships",
headers=headers,
params={"profile_id": f"neq.{KCAR_ID}"},
)
logger.info(" kcar → admin_profiles restored ✓")
results["supabase"] = {
"tables_cleared": cleared,
"tables_failed": failed,
"deleted_users": deleted_emails,
}
results["supabase"] = {"deleted_users": deleted_emails}
logger.info("\n" + "=" * 60)
logger.info("RESET COMPLETE")

View File

@ -383,9 +383,8 @@ def seed() -> Dict[str, Any]:
_rest_upsert(url, headers, "admin_profiles", {
"id": KCAR_ID,
"email": "kcar@kevlarai.com",
"display_name": "Kevin Carroll",
"admin_role": "super_admin",
"is_super_admin": True,
"role": "super_admin",
"permissions": ["all"],
"metadata": {"seeded": True},
}, on_conflict="id")
logger.info(" kcar → admin_profiles ✓")
@ -393,7 +392,7 @@ def seed() -> Dict[str, Any]:
errors.append(f"kcar_admin: {e}")
logger.error(f" {e}")
# Fix kcar's auth user_metadata and profiles.user_type to "platform_admin".
# Fix kcar's auth user_metadata so user_type is "platform_admin", not "teacher".
# Without this, POST /user/init assigns kcar to the default school on first login.
try:
r = requests.put(
@ -402,26 +401,12 @@ def seed() -> Dict[str, Any]:
json={"user_metadata": {"user_type": "platform_admin"}},
)
if r.status_code in (200, 201):
logger.info(" kcar → auth user_metadata: platform_admin ✓")
logger.info(" kcar → user_type: platform_admin ✓")
else:
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
except Exception as e:
errors.append(f"kcar_user_type: {e}")
logger.error(f" {e}")
try:
r = requests.patch(
f"{url}/rest/v1/profiles",
headers={**headers, "Prefer": "return=minimal"},
params={"id": f"eq.{KCAR_ID}"},
json={"school_id": None},
)
if r.status_code in (200, 204):
logger.info(" kcar → profiles.school_id: null ✓")
else:
logger.warning(f" kcar profiles patch failed ({r.status_code}): {r.text[:120]}")
except Exception as e:
errors.append(f"kcar_profile: {e}")
logger.error(f" {e}")
# ── Summary ───────────────────────────────────────────────────────────────
results["success"] = len(errors) == 0

View File

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