diff --git a/main.py b/main.py index a786d1c..3834e82 100644 --- a/main.py +++ b/main.py @@ -368,6 +368,7 @@ Startup modes: infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets) demo-school - Create demo school (KevlarAI) demo-users - Create demo users + seed-test - Seed full test environment (2 schools, all test users) gais-data - Import GAIS data (Edubase, etc.) dev - Run development server with auto-reload prod - Run production server (for Docker/containerized deployment) @@ -376,7 +377,7 @@ Startup modes: parser.add_argument( '--mode', '-m', - choices=['infra', 'demo-school', 'demo-users', 'gais-data', 'dev', 'prod'], + choices=['infra', 'demo-school', 'demo-users', 'seed-test', 'gais-data', 'dev', 'prod'], default='dev', help='Startup mode (default: dev)' ) @@ -409,6 +410,13 @@ if __name__ == "__main__": success = run_demo_users_mode() sys.exit(0 if success else 1) + elif args.mode == 'seed-test': + from run.initialization.seed_test_environment import seed_test_environment + import json + result = seed_test_environment() + print(json.dumps(result, indent=2)) + sys.exit(0 if result.get('success') else 1) + elif args.mode == 'gais-data': # Run GAIS data import success = run_gais_data_mode() diff --git a/modules/auth/platform_admin.py b/modules/auth/platform_admin.py new file mode 100644 index 0000000..1ccc54b --- /dev/null +++ b/modules/auth/platform_admin.py @@ -0,0 +1,57 @@ +""" +FastAPI dependencies for platform-level admin access. + +Two tiers: + require_platform_admin — user must be in admin_profiles + require_super_admin — user must have is_super_admin=True in admin_profiles + +Usage: + @router.get("/admin/schools") + async def list_all_schools(admin=Depends(require_platform_admin)): + ... + + @router.post("/admin/provision") + async def provision(admin=Depends(require_super_admin)): + ... +""" +from fastapi import Depends, HTTPException +from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.supabase.utils.client import SupabaseServiceRoleClient + + +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +async def require_platform_admin( + credentials: dict = Depends(SupabaseBearer()), +) -> dict: + """Require the caller to be a registered platform admin (in admin_profiles).""" + user_id = credentials.get("sub") + if not user_id: + raise HTTPException(status_code=403, detail="Invalid token") + try: + sb = _sb() + result = ( + sb.supabase.table("admin_profiles") + .select("id,admin_role,is_super_admin") + .eq("id", user_id) + .single() + .execute() + ) + if not result.data: + raise HTTPException(status_code=403, detail="Platform admin access required") + return {**credentials, "admin_profile": result.data} + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=403, detail="Platform admin access required") + + +async def require_super_admin( + admin: dict = Depends(require_platform_admin), +) -> dict: + """Require the caller to have is_super_admin=True.""" + if not admin.get("admin_profile", {}).get("is_super_admin"): + raise HTTPException(status_code=403, detail="Super admin access required") + return admin diff --git a/modules/database/services/provisioning_service.py b/modules/database/services/provisioning_service.py index 189c14e..52b79bb 100644 --- a/modules/database/services/provisioning_service.py +++ b/modules/database/services/provisioning_service.py @@ -229,6 +229,7 @@ class ProvisioningService: "neo4j_private_db_name": school_db, "neo4j_private_sync_status": "ready", "neo4j_private_sync_at": datetime.utcnow().isoformat(), + "neo4j_uuid_string": self._sanitize_component(institute_id), } try: ( diff --git a/routers/database/tools/classes_router.py b/routers/database/tools/classes_router.py new file mode 100644 index 0000000..68c6fd1 --- /dev/null +++ b/routers/database/tools/classes_router.py @@ -0,0 +1,620 @@ +""" +Classes Router — Supabase-backed CRUD for the `classes` table. +Institute members can read their institute's classes. +School admins and teachers can create classes. +School admins manage teacher/student assignments; teachers can leave/enroll. +""" +import os +from datetime import datetime +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from modules.logger_tool import initialise_logger +from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.supabase.utils.client import SupabaseServiceRoleClient + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) +router = APIRouter() + + +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +def _resolve_institute_id(user_id: str) -> Optional[str]: + """Return the Supabase institute UUID for this user via profiles.school_id.""" + try: + sb = _sb() + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + return str((p.data or {}).get("school_id") or "") + except Exception: + return None + + +def _require_institute(user_id: str) -> str: + """Return institute_id or raise 400.""" + institute_id = _resolve_institute_id(user_id) + if not institute_id: + raise HTTPException(status_code=400, detail="User is not linked to a school") + return institute_id + + +def _is_school_admin(user_id: str, institute_id: str) -> bool: + try: + sb = _sb() + r = ( + sb.supabase.table("institute_memberships") + .select("role") + .eq("profile_id", user_id) + .eq("institute_id", institute_id) + .in_("role", ["school_admin", "department_head"]) + .limit(1) + .execute() + ) + return bool(r.data) + except Exception: + return False + + +# ─── Request models ─────────────────────────────────────────────────────────── + +class CreateClassRequest(BaseModel): + name: str + class_code: Optional[str] = None + subject: Optional[str] = None + key_stage: Optional[str] = None + year_group: Optional[str] = None + academic_year: Optional[str] = None + description: Optional[str] = None + + +class UpdateClassRequest(BaseModel): + name: Optional[str] = None + class_code: Optional[str] = None + subject: Optional[str] = None + key_stage: Optional[str] = None + year_group: Optional[str] = None + academic_year: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + + +class AddTeacherRequest(BaseModel): + teacher_id: str + is_primary: bool = False + + +class AddStudentRequest(BaseModel): + student_id: str + + +# ─── Endpoints ──────────────────────────────────────────────────────────────── + +@router.get("") +async def list_classes( + subject: Optional[str] = None, + school_year: Optional[str] = None, + academic_year: Optional[str] = None, + key_stage: Optional[str] = None, + year_group: Optional[str] = None, + search: Optional[str] = None, + active_only: bool = True, + skip: int = 0, + limit: int = 50, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + q = sb.supabase.table("classes").select("*", count="exact").eq("institute_id", institute_id) + if active_only: + q = q.eq("is_active", True) + if subject: + q = q.eq("subject", subject) + # support both param names + yr = academic_year or school_year + if yr: + q = q.eq("academic_year", yr) + if key_stage: + q = q.eq("key_stage", key_stage) + if year_group: + q = q.eq("year_group", year_group) + if search: + q = q.ilike("name", f"%{search}%") + + q = q.order("name").range(skip, skip + limit - 1) + res = q.execute() + + classes = res.data or [] + total = res.count or 0 + + # Attach student/teacher counts + class_ids = [c["id"] for c in classes] + teacher_counts: Dict[str, int] = {} + student_counts: Dict[str, int] = {} + if class_ids: + try: + tc = ( + sb.supabase.table("class_teachers") + .select("class_id", count="exact") + .in_("class_id", class_ids) + .execute() + ) + for row in (tc.data or []): + teacher_counts[row["class_id"]] = teacher_counts.get(row["class_id"], 0) + 1 + + sc = ( + sb.supabase.table("class_students") + .select("class_id") + .in_("class_id", class_ids) + .eq("status", "active") + .execute() + ) + for row in (sc.data or []): + student_counts[row["class_id"]] = student_counts.get(row["class_id"], 0) + 1 + except Exception: + pass + + enriched = [ + {**c, "teacher_count": teacher_counts.get(c["id"], 0), "student_count": student_counts.get(c["id"], 0)} + for c in classes + ] + return {"classes": enriched, "total": total} + + +@router.get("/me/teacher") +async def my_teaching_classes( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + assigned = ( + sb.supabase.table("class_teachers") + .select("class_id, is_primary") + .eq("teacher_id", user_id) + .execute() + .data or [] + ) + if not assigned: + return {"classes": []} + + class_ids = [a["class_id"] for a in assigned] + is_primary_map = {a["class_id"]: a["is_primary"] for a in assigned} + + res = ( + sb.supabase.table("classes") + .select("*") + .in_("id", class_ids) + .eq("institute_id", institute_id) + .eq("is_active", True) + .order("name") + .execute() + .data or [] + ) + enriched = [{**c, "is_primary_teacher": is_primary_map.get(c["id"], False)} for c in res] + return {"classes": enriched} + + +@router.get("/me/student") +async def my_student_classes( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + enrolled = ( + sb.supabase.table("class_students") + .select("class_id") + .eq("student_id", user_id) + .eq("status", "active") + .execute() + .data or [] + ) + if not enrolled: + return {"classes": []} + + class_ids = [e["class_id"] for e in enrolled] + res = ( + sb.supabase.table("classes") + .select("*") + .in_("id", class_ids) + .eq("institute_id", institute_id) + .eq("is_active", True) + .order("name") + .execute() + .data or [] + ) + return {"classes": res} + + +@router.get("/{class_id}") +async def get_class( + class_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + cls_res = ( + sb.supabase.table("classes") + .select("*") + .eq("id", class_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if not cls_res.data: + raise HTTPException(status_code=404, detail="Class not found") + + teachers = ( + sb.supabase.table("class_teachers") + .select("teacher_id, is_primary, can_edit, assigned_at") + .eq("class_id", class_id) + .execute() + .data or [] + ) + students = ( + sb.supabase.table("class_students") + .select("student_id, status, enrolled_at") + .eq("class_id", class_id) + .eq("status", "active") + .execute() + .data or [] + ) + + # Enrich with profile data + all_ids = [t["teacher_id"] for t in teachers] + [s["student_id"] for s in students] + profile_map: Dict[str, Dict] = {} + if all_ids: + profiles = ( + sb.supabase.table("profiles") + .select("id, full_name, display_name, email, user_type") + .in_("id", list(set(all_ids))) + .execute() + .data or [] + ) + profile_map = {p["id"]: p for p in profiles} + + for t in teachers: + t["profile"] = profile_map.get(t["teacher_id"], {}) + for s in students: + s["profile"] = profile_map.get(s["student_id"], {}) + + # Enrollment requests (pending) + reqs = ( + sb.supabase.table("enrollment_requests") + .select("id, student_id, status, created_at") + .eq("class_id", class_id) + .eq("status", "pending") + .execute() + .data or [] + ) + req_student_ids = [r["student_id"] for r in reqs if r.get("student_id")] + req_profiles: Dict[str, Dict] = {} + if req_student_ids: + rp = ( + sb.supabase.table("profiles") + .select("id, full_name, email") + .in_("id", req_student_ids) + .execute() + .data or [] + ) + req_profiles = {p["id"]: p for p in rp} + for r in reqs: + r["profile"] = req_profiles.get(r.get("student_id", ""), {}) + + return { + **cls_res.data, + "teachers": teachers, + "students": students, + "enrollment_requests": reqs, + "student_count": len(students), + } + + +@router.post("") +async def create_class( + body: CreateClassRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + row = { + "institute_id": institute_id, + "name": body.name, + "created_by": user_id, + } + for field in ("class_code", "subject", "key_stage", "year_group", "academic_year", "description"): + val = getattr(body, field) + if val is not None: + row[field] = val + + res = sb.supabase.table("classes").insert(row).execute() + new_class = (res.data or [{}])[0] + class_id = new_class.get("id") + + # Auto-assign creator as primary teacher if they have teacher role + if class_id: + try: + mem = ( + sb.supabase.table("institute_memberships") + .select("role") + .eq("profile_id", user_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + if (mem.data or {}).get("role") in ("teacher", "department_head"): + sb.supabase.table("class_teachers").insert({ + "class_id": class_id, + "teacher_id": user_id, + "is_primary": True, + "assigned_by": user_id, + }).execute() + except Exception: + pass + + logger.info(f"Class created: {class_id} '{body.name}' by {user_id}") + return new_class + + +@router.patch("/{class_id}") +async def update_class( + class_id: str, + body: UpdateClassRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + # Must be school admin OR primary teacher of this class + is_admin = _is_school_admin(user_id, institute_id) + if not is_admin: + ct = ( + sb.supabase.table("class_teachers") + .select("is_primary") + .eq("class_id", class_id) + .eq("teacher_id", user_id) + .single() + .execute() + ) + if not (ct.data or {}).get("is_primary"): + raise HTTPException(status_code=403, detail="Only school admins or the primary teacher can update this class") + + updates = {k: v for k, v in body.dict().items() if v is not None} + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + updates["updated_at"] = datetime.utcnow().isoformat() + + res = ( + sb.supabase.table("classes") + .update(updates) + .eq("id", class_id) + .eq("institute_id", institute_id) + .execute() + ) + return (res.data or [{}])[0] + + +@router.delete("/{class_id}") +async def delete_class( + class_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + + if not _is_school_admin(user_id, institute_id): + raise HTTPException(status_code=403, detail="Only school admins can delete classes") + + sb = _sb() + sb.supabase.table("classes").update({"is_active": False}).eq("id", class_id).eq("institute_id", institute_id).execute() + return {"status": "ok"} + + +@router.post("/{class_id}/teachers") +async def add_teacher( + class_id: str, + body: AddTeacherRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + + if not _is_school_admin(user_id, institute_id): + raise HTTPException(status_code=403, detail="Only school admins can assign teachers") + + sb = _sb() + res = sb.supabase.table("class_teachers").upsert({ + "class_id": class_id, + "teacher_id": body.teacher_id, + "is_primary": body.is_primary, + "assigned_by": user_id, + }, on_conflict="class_id,teacher_id").execute() + return {"status": "ok", "row": (res.data or [{}])[0]} + + +@router.delete("/{class_id}/teachers/{teacher_id}") +async def remove_teacher( + class_id: str, + teacher_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + + if not _is_school_admin(user_id, institute_id): + raise HTTPException(status_code=403, detail="Only school admins can remove teachers") + + sb = _sb() + sb.supabase.table("class_teachers").delete().eq("class_id", class_id).eq("teacher_id", teacher_id).execute() + return {"status": "ok"} + + +@router.get("/school/students") +async def list_school_students( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """List all students in the caller's school. Used by admin to add students to a class.""" + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + members = ( + sb.supabase.table("institute_memberships") + .select("profile_id") + .eq("institute_id", institute_id) + .eq("role", "student") + .execute() + .data or [] + ) + student_ids = [m["profile_id"] for m in members] + if not student_ids: + return {"students": []} + profiles = ( + sb.supabase.table("profiles") + .select("id, full_name, display_name, email, user_type") + .in_("id", student_ids) + .order("full_name") + .execute() + .data or [] + ) + return {"students": profiles} + + +@router.post("/{class_id}/students") +async def add_student( + class_id: str, + body: AddStudentRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + + if not _is_school_admin(user_id, institute_id): + raise HTTPException(status_code=403, detail="Only school admins can enroll students") + + sb = _sb() + res = sb.supabase.table("class_students").upsert({ + "class_id": class_id, + "student_id": body.student_id, + "status": "active", + "enrolled_by": user_id, + }, on_conflict="class_id,student_id").execute() + return {"status": "ok", "row": (res.data or [{}])[0]} + + +@router.delete("/{class_id}/students/{student_id}") +async def remove_student( + class_id: str, + student_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + + if not _is_school_admin(user_id, institute_id): + raise HTTPException(status_code=403, detail="Only school admins can remove students") + + sb = _sb() + sb.supabase.table("class_students").delete().eq("class_id", class_id).eq("student_id", student_id).execute() + return {"status": "ok"} + + +@router.post("/{class_id}/leave") +async def leave_class( + class_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + sb = _sb() + sb.supabase.table("class_students").update({"status": "inactive"}).eq("class_id", class_id).eq("student_id", user_id).execute() + return {"status": "ok"} + + +@router.get("/{class_id}/enrollment-requests") +async def list_enrollment_requests( + class_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + + sb = _sb() + reqs = ( + sb.supabase.table("enrollment_requests") + .select("*") + .eq("class_id", class_id) + .eq("status", "pending") + .execute() + .data or [] + ) + return {"requests": reqs} + + +@router.post("/{class_id}/enroll") +async def request_enrollment( + class_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + sb = _sb() + res = sb.supabase.table("enrollment_requests").insert({ + "class_id": class_id, + "student_id": user_id, + "status": "pending", + }).execute() + return {"status": "ok", "request": (res.data or [{}])[0]} + + +@router.patch("/{class_id}/enrollment-requests/{request_id}") +async def respond_enrollment_request( + class_id: str, + request_id: str, + body: dict, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """Approve or reject a pending enrollment request. School admin only.""" + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + if not _is_school_admin(user_id, institute_id): + raise HTTPException(status_code=403, detail="Only school admins can respond to enrollment requests") + + action = body.get("action") + if action not in ("approve", "reject"): + raise HTTPException(status_code=400, detail="action must be 'approve' or 'reject'") + + sb = _sb() + req = ( + sb.supabase.table("enrollment_requests") + .select("student_id, status") + .eq("id", request_id) + .eq("class_id", class_id) + .single() + .execute() + ) + if not req.data: + raise HTTPException(status_code=404, detail="Request not found") + if req.data["status"] != "pending": + raise HTTPException(status_code=400, detail="Request is not pending") + + student_id = req.data["student_id"] + new_status = "accepted" if action == "approve" else "rejected" + sb.supabase.table("enrollment_requests").update({"status": new_status}).eq("id", request_id).execute() + + if action == "approve": + sb.supabase.table("class_students").upsert({ + "class_id": class_id, + "student_id": student_id, + "status": "active", + "enrolled_by": user_id, + }, on_conflict="class_id,student_id").execute() + + return {"status": "ok", "action": action, "student_id": student_id} diff --git a/routers/database/tools/graph_tree_router.py b/routers/database/tools/graph_tree_router.py index 02524a6..4b54b75 100644 --- a/routers/database/tools/graph_tree_router.py +++ b/routers/database/tools/graph_tree_router.py @@ -1,10 +1,10 @@ import os -from datetime import datetime from typing import Dict, Any, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException from modules.logger_tool import initialise_logger from modules.auth.supabase_bearer import SupabaseBearer import modules.database.tools.neo4j_driver_tools as driver_tools +from modules.database.supabase.utils.client import SupabaseServiceRoleClient logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) router = APIRouter() @@ -16,6 +16,50 @@ def _user_to_teacher_db(user_id: str) -> str: return f"cc.users.teacher.{user_id.replace('-', '')}" +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +def _find_teacher_uuid(db: str, user_email: str) -> Optional[str]: + """Query teacher UUID from a known Neo4j institute DB.""" + try: + with driver_tools.get_session(database=db) as session: + rec = session.run( + 'MATCH (t:Teacher) WHERE t.worker_email = $email ' + 'RETURN t.uuid_string AS uuid LIMIT 1', + email=user_email, + ).single() + if rec: + return rec['uuid'] + except Exception: + pass + return None + + +def _resolve_institute( + user_id: str, user_email: str +) -> tuple: + """Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid). + Supabase-first lookup with Neo4j email-scan fallback.""" + try: + sb = _sb() + p = sb.supabase.table('profiles').select('school_id').eq('id', user_id).single().execute() + school_id = (p.data or {}).get('school_id') + if school_id: + i = sb.supabase.table('institutes').select('id,neo4j_uuid_string').eq('id', str(school_id)).single().execute() + inst = i.data or {} + neo4j_uuid = inst.get('neo4j_uuid_string') + if neo4j_uuid: + db = f'cc.institutes.{neo4j_uuid}' + teacher_uuid = _find_teacher_uuid(db, user_email) + return str(school_id), db, teacher_uuid + except Exception as e: + logger.warning(f'Supabase-first institute resolve failed: {e}') + # Fallback: scan Neo4j + db, teacher_uuid = _find_teacher_institute(user_email) + return None, db, teacher_uuid + + def _find_teacher_institute(user_email: str) -> Tuple[Optional[str], Optional[str]]: """Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs.""" if not user_email: @@ -164,21 +208,31 @@ def _section(section_id: str, label: str, db: str, status: str, def _build_calendar_section() -> Dict: - current_year = str(datetime.now().year) - months = _query_calendar_months(current_year) - calendar_year_node = { - "neo4j_node_id": current_year, - "label": current_year, - "node_type": "CalendarYear", - "neo4j_db_name": "classroomcopilot", - "is_section": False, - "has_children": True, - "children": months, - } - return _section( - "calendar", "Calendar", "classroomcopilot", "populated", - has_children=True, children=[calendar_year_node], - ) + try: + with driver_tools.get_session(database="classroomcopilot") as session: + rows = session.run( + "MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)" + ).data() + if not rows: + return _section("calendar", "Calendar", "classroomcopilot", "empty") + year_nodes = [ + { + "neo4j_node_id": r["y"]["uuid_string"], + "label": r["y"].get("year") or r["y"]["uuid_string"], + "node_type": "CalendarYear", + "neo4j_db_name": "classroomcopilot", + "is_section": False, + "has_children": True, + } + for r in rows + ] + return _section( + "calendar", "Calendar", "classroomcopilot", "populated", + has_children=True, children=year_nodes, + ) + except Exception as e: + logger.warning(f"Calendar section build failed: {e}") + return _section("calendar", "Calendar", "classroomcopilot", "empty") def _build_timetable_section(institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict: @@ -508,7 +562,7 @@ async def get_teacher_graph_tree( "has_children": True, } - institute_db, teacher_node_uuid = _find_teacher_institute(user_email) + _, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email) sections = [ _build_calendar_section(), @@ -546,8 +600,9 @@ async def get_node_children( @router.get("/calendar/academic") async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: + user_id = credentials.get("sub", "") user_email = credentials.get("email", "") - institute_db, _ = _find_teacher_institute(user_email) + _, institute_db, _ = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "no_school", "terms": []} try: diff --git a/routers/database/tools/invitations_router.py b/routers/database/tools/invitations_router.py new file mode 100644 index 0000000..e2498b4 --- /dev/null +++ b/routers/database/tools/invitations_router.py @@ -0,0 +1,372 @@ +""" +Invitations & People Router. + +POST /users/invite — school admin sends magic-link invitation +GET /users/invitations — list invitations for the school +DELETE /users/invitations/{id} — cancel a pending invitation +POST /users/invitations/{id}/resend — re-send magic link +GET /users/staff — list current staff members +GET /users/students — list current student members +""" +import os +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, EmailStr +from modules.logger_tool import initialise_logger +from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.supabase.utils.client import SupabaseServiceRoleClient + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) +router = APIRouter() + +VALID_ROLES = {"teacher", "student", "school_admin", "department_head"} +STAFF_ROLES = {"teacher", "school_admin", "department_head"} + + +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +def _resolve_institute_id(user_id: str) -> Optional[str]: + try: + sb = _sb() + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + return str((p.data or {}).get("school_id") or "") or None + except Exception: + return None + + +def _require_institute(user_id: str) -> str: + iid = _resolve_institute_id(user_id) + if not iid: + raise HTTPException(status_code=400, detail="User is not linked to a school") + return iid + + +def _require_school_admin(user_id: str, institute_id: str) -> None: + try: + sb = _sb() + m = ( + sb.supabase.table("institute_memberships") + .select("role") + .eq("profile_id", user_id) + .eq("institute_id", institute_id) + .single() + .execute() + ) + role = (m.data or {}).get("role", "") + if role not in ("school_admin", "department_head"): + raise HTTPException(status_code=403, detail="School admin access required") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=403, detail="Could not verify school admin status") + + +# ─── Request models ─────────────────────────────────────────────────────────── + +class InviteRequest(BaseModel): + email: str + role: str # teacher | student | school_admin | department_head + metadata: Optional[Dict[str, Any]] = None # year_group, subject, department, etc. + + +# ─── Invite ─────────────────────────────────────────────────────────────────── + +@router.post("/invite") +async def invite_user( + body: InviteRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Create an invitation row and send a Supabase magic-link invite email. + Idempotent: if a pending invitation already exists for the same email/school, + it is returned (use /resend to refresh the magic link). + """ + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + _require_school_admin(user_id, institute_id) + + if body.role not in VALID_ROLES: + raise HTTPException(status_code=400, detail=f"role must be one of {sorted(VALID_ROLES)}") + + email = body.email.strip().lower() + sb = _sb() + + # Check for existing pending invitation + existing = ( + sb.supabase.table("invitations") + .select("id,status,email,role,created_at,expires_at") + .eq("institute_id", institute_id) + .eq("email", email) + .eq("status", "pending") + .execute() + .data or [] + ) + if existing: + return { + "status": "already_pending", + "invitation": existing[0], + "message": "A pending invitation already exists. Use /resend to refresh the magic link.", + } + + # Insert invitation row + expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat() + inv_data = { + "institute_id": institute_id, + "email": email, + "role": body.role, + "invited_by": user_id, + "expires_at": expires_at, + "status": "pending", + "metadata": body.metadata or {}, + } + inv_res = sb.supabase.table("invitations").insert(inv_data).execute() + if not inv_res.data: + raise HTTPException(status_code=500, detail="Failed to create invitation record") + invitation = inv_res.data[0] + + # Send magic link via Supabase Auth admin + try: + sb.supabase.auth.admin.invite_user_by_email( + email, + options={ + "data": { + "invitation_id": invitation["id"], + "institute_id": institute_id, + "role": body.role, + **(body.metadata or {}), + } + }, + ) + magic_link_sent = True + except Exception as e: + logger.warning(f"Magic link send failed for {email}: {e}") + magic_link_sent = False + + logger.info(f"Invited {email} as {body.role} to school {institute_id} by {user_id}") + return { + "status": "ok", + "invitation": invitation, + "magic_link_sent": magic_link_sent, + } + + +# ─── List invitations ───────────────────────────────────────────────────────── + +@router.get("/invitations") +async def list_invitations( + role: Optional[str] = Query(None), + status: Optional[str] = Query(None, description="pending|accepted|expired|cancelled"), + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + _require_school_admin(user_id, institute_id) + + sb = _sb() + q = ( + sb.supabase.table("invitations") + .select("id,email,role,status,invited_by,expires_at,created_at,metadata") + .eq("institute_id", institute_id) + .order("created_at", desc=True) + ) + if role: + q = q.eq("role", role) + if status: + q = q.eq("status", status) + + rows = q.execute().data or [] + + # Mark expired rows (status still 'pending' but past expires_at) + now = datetime.now(timezone.utc) + for row in rows: + if row["status"] == "pending": + try: + exp = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00")) + if exp < now: + row["status"] = "expired" + except Exception: + pass + + return {"status": "ok", "invitations": rows, "total": len(rows)} + + +# ─── Cancel invitation ──────────────────────────────────────────────────────── + +@router.delete("/invitations/{invitation_id}") +async def cancel_invitation( + invitation_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + _require_school_admin(user_id, institute_id) + + sb = _sb() + res = ( + sb.supabase.table("invitations") + .update({"status": "cancelled"}) + .eq("id", invitation_id) + .eq("institute_id", institute_id) + .eq("status", "pending") + .execute() + ) + if not res.data: + raise HTTPException(status_code=404, detail="Pending invitation not found") + return {"status": "ok", "invitation": res.data[0]} + + +# ─── Resend invitation ──────────────────────────────────────────────────────── + +@router.post("/invitations/{invitation_id}/resend") +async def resend_invitation( + invitation_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """Re-trigger the magic link for a pending (or expired) invitation.""" + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + _require_school_admin(user_id, institute_id) + + sb = _sb() + inv = ( + sb.supabase.table("invitations") + .select("*") + .eq("id", invitation_id) + .eq("institute_id", institute_id) + .single() + .execute() + ).data + if not inv: + raise HTTPException(status_code=404, detail="Invitation not found") + if inv["status"] == "accepted": + raise HTTPException(status_code=400, detail="Invitation already accepted") + if inv["status"] == "cancelled": + raise HTTPException(status_code=400, detail="Invitation was cancelled — create a new one") + + # Refresh expiry + status + new_expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat() + sb.supabase.table("invitations").update({ + "expires_at": new_expires, + "status": "pending", + }).eq("id", invitation_id).execute() + + # Re-send magic link + try: + sb.supabase.auth.admin.invite_user_by_email( + inv["email"], + options={ + "data": { + "invitation_id": invitation_id, + "institute_id": institute_id, + "role": inv["role"], + **inv.get("metadata", {}), + } + }, + ) + magic_link_sent = True + except Exception as e: + logger.warning(f"Resend magic link failed for {inv['email']}: {e}") + magic_link_sent = False + + return {"status": "ok", "magic_link_sent": magic_link_sent, "new_expires_at": new_expires} + + +# ─── Staff list ─────────────────────────────────────────────────────────────── + +@router.get("/staff") +async def list_staff( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """List all staff members (teachers, admins, department heads) in the school.""" + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + _require_school_admin(user_id, institute_id) + + sb = _sb() + members = ( + sb.supabase.table("institute_memberships") + .select("profile_id,role,joined_at") + .eq("institute_id", institute_id) + .in_("role", list(STAFF_ROLES)) + .execute() + .data or [] + ) + + if not members: + return {"status": "ok", "staff": [], "total": 0} + + profile_ids = [m["profile_id"] for m in members] + profiles = ( + sb.supabase.table("profiles") + .select("id,email,username,display_name") + .in_("id", profile_ids) + .execute() + .data or [] + ) + profile_map = {p["id"]: p for p in profiles} + + staff = [] + for m in members: + p = profile_map.get(m["profile_id"], {}) + staff.append({ + "profile_id": m["profile_id"], + "email": p.get("email"), + "username": p.get("username"), + "display_name": p.get("display_name"), + "role": m["role"], + "joined_at": m["joined_at"], + }) + + return {"status": "ok", "staff": staff, "total": len(staff)} + + +# ─── Student list ───────────────────────────────────────────────────────────── + +@router.get("/students") +async def list_students( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """List all student members in the school.""" + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + _require_school_admin(user_id, institute_id) + + sb = _sb() + members = ( + sb.supabase.table("institute_memberships") + .select("profile_id,role,joined_at") + .eq("institute_id", institute_id) + .eq("role", "student") + .execute() + .data or [] + ) + + if not members: + return {"status": "ok", "students": [], "total": 0} + + profile_ids = [m["profile_id"] for m in members] + profiles = ( + sb.supabase.table("profiles") + .select("id,email,username,display_name") + .in_("id", profile_ids) + .execute() + .data or [] + ) + profile_map = {p["id"]: p for p in profiles} + + students = [] + for m in members: + p = profile_map.get(m["profile_id"], {}) + students.append({ + "profile_id": m["profile_id"], + "email": p.get("email"), + "username": p.get("username"), + "display_name": p.get("display_name"), + "role": m["role"], + "joined_at": m["joined_at"], + }) + + return {"status": "ok", "students": students, "total": len(students)} diff --git a/routers/database/tools/platform_admin_router.py b/routers/database/tools/platform_admin_router.py new file mode 100644 index 0000000..50619fd --- /dev/null +++ b/routers/database/tools/platform_admin_router.py @@ -0,0 +1,148 @@ +""" +Platform Admin Router — super_admin / platform_admin operations. + +GET /admin/schools — list all institutes with member + calendar counts +GET /admin/stats — platform-level summary +""" +import os +from typing import Any, Dict, List +from fastapi import APIRouter, Depends, HTTPException +from modules.logger_tool import initialise_logger +from modules.auth.supabase_bearer import SupabaseBearer +from modules.auth.platform_admin import require_platform_admin +from modules.database.supabase.utils.client import SupabaseServiceRoleClient + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) +router = APIRouter() + + +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +@router.get("/schools") +async def list_all_schools( + _: dict = Depends(require_platform_admin), +) -> Dict[str, Any]: + """List every institute with basic counts. Platform admin only.""" + sb = _sb() + + institutes = ( + sb.supabase.table("institutes") + .select("id,name,urn,website,status,created_at,neo4j_uuid_string") + .order("name") + .execute() + .data or [] + ) + + if not institutes: + return {"status": "ok", "schools": [], "total": 0} + + inst_ids = [i["id"] for i in institutes] + + # Member counts per institute + all_members = ( + sb.supabase.table("institute_memberships") + .select("institute_id,role") + .in_("institute_id", inst_ids) + .execute() + .data or [] + ) + from collections import defaultdict + member_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: {"staff": 0, "students": 0}) + staff_roles = {"teacher", "school_admin", "department_head"} + for m in all_members: + iid = m["institute_id"] + if m["role"] in staff_roles: + member_counts[iid]["staff"] += 1 + elif m["role"] == "student": + member_counts[iid]["students"] += 1 + + # Calendar presence per institute + term_rows = ( + sb.supabase.table("academic_terms") + .select("institute_id") + .in_("institute_id", inst_ids) + .execute() + .data or [] + ) + has_calendar = {r["institute_id"] for r in term_rows} + + # Pending invitations count + inv_rows = ( + sb.supabase.table("invitations") + .select("institute_id") + .eq("status", "pending") + .in_("institute_id", inst_ids) + .execute() + .data or [] + ) + from collections import Counter + inv_counts = Counter(r["institute_id"] for r in inv_rows) + + schools = [] + for inst in institutes: + iid = inst["id"] + mc = member_counts.get(iid, {}) + schools.append({ + **inst, + "staff_count": mc.get("staff", 0), + "student_count": mc.get("students", 0), + "has_calendar": iid in has_calendar, + "pending_invitations": inv_counts.get(iid, 0), + }) + + return {"status": "ok", "schools": schools, "total": len(schools)} + + +@router.get("/stats") +async def platform_stats( + _: dict = Depends(require_platform_admin), +) -> Dict[str, Any]: + """High-level platform counts. Platform admin only.""" + sb = _sb() + + inst_count = len( + sb.supabase.table("institutes").select("id").execute().data or [] + ) + profile_count = len( + sb.supabase.table("profiles").select("id").execute().data or [] + ) + lesson_count = len( + sb.supabase.table("taught_lessons").select("id").execute().data or [] + ) + inv_count = len( + sb.supabase.table("invitations").select("id").eq("status", "pending").execute().data or [] + ) + + return { + "status": "ok", + "schools": inst_count, + "profiles": profile_count, + "taught_lessons": lesson_count, + "pending_invitations": inv_count, + } + + +@router.post("/reset") +async def reset_environment( + _: dict = Depends(require_platform_admin), +) -> Dict[str, Any]: + """DESTRUCTIVE: wipe all test data. Neo4j + Supabase. Platform admin only.""" + import asyncio + from run.initialization.reset_environment import reset as _reset + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, _reset) + return {"status": "ok", **result} + + +@router.post("/seed") +async def seed_environment( + _: dict = Depends(require_platform_admin), +) -> Dict[str, Any]: + """Idempotent rebuild: both schools, global calendar, 20 test accounts. Platform admin only.""" + import asyncio + from run.initialization.seed_environment import seed as _seed + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, _seed) + return {"status": "ok", **result} diff --git a/routers/database/tools/school_router.py b/routers/database/tools/school_router.py index be580ff..45f9bde 100644 --- a/routers/database/tools/school_router.py +++ b/routers/database/tools/school_router.py @@ -318,3 +318,286 @@ def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: s "institute_id": school_id, "role": role, }).execute() + + +# ─── School Overview ────────────────────────────────────────────────────────── + +@router.get("/overview") +async def get_school_overview( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Summary dashboard for school admins: staff/student/class counts, + calendar snapshot (terms, total academic days, current/next term). + """ + user_id = credentials.get("sub", "") + sb = _get_sb() + + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + school_id = str((p.data or {}).get("school_id") or "") + if not school_id: + raise HTTPException(status_code=400, detail="User is not linked to a school") + + # Role check + mem = ( + sb.supabase.table("institute_memberships") + .select("role") + .eq("profile_id", user_id) + .eq("institute_id", school_id) + .single() + .execute() + ) + user_role = (mem.data or {}).get("role", "teacher") + + # Counts + staff_roles = ["teacher", "school_admin", "department_head"] + staff_rows = ( + sb.supabase.table("institute_memberships") + .select("profile_id", count="exact") + .eq("institute_id", school_id) + .in_("role", staff_roles) + .execute() + ) + student_rows = ( + sb.supabase.table("institute_memberships") + .select("profile_id", count="exact") + .eq("institute_id", school_id) + .eq("role", "student") + .execute() + ) + class_rows = ( + sb.supabase.table("classes") + .select("id", count="exact") + .eq("institute_id", school_id) + .eq("is_active", True) + .execute() + ) + + # Calendar snapshot from academic_terms + terms = ( + sb.supabase.table("academic_terms") + .select("id,term_name,term_number,start_date,end_date") + .eq("institute_id", school_id) + .order("term_number") + .execute() + .data or [] + ) + + # Academic day counts per term + if terms: + term_ids = [t["id"] for t in terms] + day_counts_res = ( + sb.supabase.table("academic_days") + .select("academic_term_id", count="exact") + .eq("institute_id", school_id) + .eq("day_type", "Academic") + .in_("academic_term_id", term_ids) + .execute() + ) + # Supabase doesn't group-by server-side; count manually per term + all_days = ( + sb.supabase.table("academic_days") + .select("academic_term_id,day_type") + .eq("institute_id", school_id) + .in_("academic_term_id", term_ids) + .execute() + .data or [] + ) + from collections import defaultdict + academic_day_count: Dict[str, int] = defaultdict(int) + total_day_count: Dict[str, int] = defaultdict(int) + for d in all_days: + total_day_count[d["academic_term_id"]] += 1 + if d["day_type"] == "Academic": + academic_day_count[d["academic_term_id"]] += 1 + + from datetime import date + today_str = str(date.today()) + for t in terms: + t["academic_days"] = academic_day_count.get(t["id"], 0) + t["total_days"] = total_day_count.get(t["id"], 0) + if t["start_date"] <= today_str <= t["end_date"]: + t["is_current"] = True + else: + t["is_current"] = False + + pending_invites = ( + sb.supabase.table("invitations") + .select("id", count="exact") + .eq("institute_id", school_id) + .eq("status", "pending") + .execute() + ) + + return { + "status": "ok", + "user_role": user_role, + "counts": { + "staff": staff_rows.count or 0, + "students": student_rows.count or 0, + "classes": class_rows.count or 0, + "pending_invitations": pending_invites.count or 0, + }, + "terms": terms, + "has_calendar": len(terms) > 0, + } + + +# ─── Calendar days (admin view) ─────────────────────────────────────────────── + +@router.get("/calendar/days") +async def list_calendar_days( + term_id: Optional[str] = None, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Return academic_days for the school, optionally filtered by term. + Includes week_cycle from the parent academic_week. + """ + user_id = credentials.get("sub", "") + sb = _get_sb() + + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + school_id = str((p.data or {}).get("school_id") or "") + if not school_id: + raise HTTPException(status_code=400, detail="User is not linked to a school") + + q = ( + sb.supabase.table("academic_days") + .select("id,date,day_of_week,day_type,academic_week_id,academic_term_id,academic_day_number,excluded_period_codes") + .eq("institute_id", school_id) + .order("date") + ) + if term_id: + q = q.eq("academic_term_id", term_id) + + days = q.execute().data or [] + + # Enrich with week_cycle + if days: + week_ids = list({d["academic_week_id"] for d in days if d.get("academic_week_id")}) + weeks = ( + sb.supabase.table("academic_weeks") + .select("id,week_number,week_cycle") + .in_("id", week_ids) + .execute() + .data or [] + ) + wk_map = {w["id"]: w for w in weeks} + for d in days: + wk = wk_map.get(d.get("academic_week_id", ""), {}) + d["week_cycle"] = wk.get("week_cycle", "") + d["week_number"] = wk.get("week_number") + + return {"status": "ok", "days": days, "total": len(days)} + + +@router.patch("/calendar/days/{day_id}") +async def update_calendar_day( + day_id: str, + body: Dict[str, Any], + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Override day_type for a single academic day (school admin only). + Syncs academic_periods: removes periods for non-Academic days, + creates periods from periods_template for newly-Academic days. + """ + user_id = credentials.get("sub", "") + sb = _get_sb() + + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + school_id = str((p.data or {}).get("school_id") or "") + if not school_id: + raise HTTPException(status_code=400, detail="User is not linked to a school") + + # Verify admin role + mem = ( + sb.supabase.table("institute_memberships") + .select("role") + .eq("profile_id", user_id) + .eq("institute_id", school_id) + .single() + .execute() + ) + if (mem.data or {}).get("role") not in ("school_admin", "department_head"): + raise HTTPException(status_code=403, detail="School admin access required") + + # Verify day belongs to school + day = ( + sb.supabase.table("academic_days") + .select("*") + .eq("id", day_id) + .eq("institute_id", school_id) + .single() + .execute() + ).data + if not day: + raise HTTPException(status_code=404, detail="Day not found") + + new_day_type = body.get("day_type", day["day_type"]) + excluded = body.get("excluded_period_codes", day.get("excluded_period_codes") or []) + + valid_types = {"Academic", "Holiday", "Staff", "OffTimetable"} + if new_day_type not in valid_types: + raise HTTPException(status_code=400, detail=f"day_type must be one of {sorted(valid_types)}") + + # Update the day + sb.supabase.table("academic_days").update({ + "day_type": new_day_type, + "excluded_period_codes": excluded, + }).eq("id", day_id).execute() + + old_type = day["day_type"] + periods_changed = 0 + + if old_type == "Academic" and new_day_type != "Academic": + # Remove periods for this day + del_res = ( + sb.supabase.table("academic_periods") + .delete() + .eq("academic_day_id", day_id) + .execute() + ) + periods_changed = -(len(del_res.data or [])) + + elif old_type != "Academic" and new_day_type == "Academic": + # Create periods from template + stt = ( + sb.supabase.table("school_timetables") + .select("periods_template") + .eq("institute_id", school_id) + .order("created_at", desc=True) + .limit(1) + .execute() + .data or [] + ) + template = (stt[0].get("periods_template") or []) if stt else [] + skip = set(excluded) + new_periods = [] + for period in template: + if period.get("code") in skip: + continue + new_periods.append({ + "academic_day_id": day_id, + "institute_id": school_id, + "period_code": period["code"], + "period_name": period.get("name", period["code"]), + "start_time": period.get("start_time"), + "end_time": period.get("end_time"), + "period_type": period.get("period_type", "lesson"), + }) + if new_periods: + ins_res = ( + sb.supabase.table("academic_periods") + .upsert(new_periods, on_conflict="academic_day_id,period_code") + .execute() + ) + periods_changed = len(ins_res.data or []) + + return { + "status": "ok", + "day_id": day_id, + "new_day_type": new_day_type, + "periods_changed": periods_changed, + } diff --git a/routers/database/tools/taught_lessons_router.py b/routers/database/tools/taught_lessons_router.py new file mode 100644 index 0000000..46a33f0 --- /dev/null +++ b/routers/database/tools/taught_lessons_router.py @@ -0,0 +1,601 @@ +""" +Taught Lessons Router — materialization and lesson CRUD. + +POST /materialize — slot template × academic_periods → taught_lessons rows +GET /lessons — teacher's lessons for a date range +GET /lessons/{id} — single lesson detail +PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned) +""" +import os +from collections import defaultdict +from datetime import datetime, date, timedelta +from typing import Any, Dict, List, Optional, Tuple +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from modules.logger_tool import initialise_logger +from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.supabase.utils.client import SupabaseServiceRoleClient + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) +router = APIRouter() + + +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() + + +def _resolve_institute_id(user_id: str) -> Optional[str]: + try: + sb = _sb() + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + return str((p.data or {}).get("school_id") or "") or None + except Exception: + return None + + +def _require_institute(user_id: str) -> str: + iid = _resolve_institute_id(user_id) + if not iid: + raise HTTPException(status_code=400, detail="User is not linked to a school") + return iid + + +# ─── Request models ─────────────────────────────────────────────────────────── + +class UpdateLessonRequest(BaseModel): + lesson_plan: Optional[Dict[str, Any]] = None + notes: Optional[str] = None + status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted + + +# ─── Materialize ───────────────────────────────────────────────────────────── + +@router.post("/materialize") +async def materialize_taught_lessons( + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Materialize taught_lessons from teacher_timetable_slots × academic_periods. + + For each slot (day_of_week + period_code + week_cycle), find every + academic_period that falls on a matching day and week cycle, then + UPSERT a taught_lesson row and a whiteboard_room for it. + + Safe to re-run; uses ON CONFLICT DO UPDATE. + """ + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + # ── 1. Get teacher timetable ────────────────────────────────────────────── + tt_rows = ( + sb.supabase.table("teacher_timetables") + .select("id") + .eq("profile_id", user_id) + .eq("institute_id", institute_id) + .limit(1) + .execute() + .data or [] + ) + if not tt_rows: + return {"status": "error", "message": "No teacher timetable found — run /timetable/init first"} + tt_id = tt_rows[0]["id"] + + # ── 2. Get teacher's timetable slots ────────────────────────────────────── + slots = ( + sb.supabase.table("teacher_timetable_slots") + .select("id,day_of_week,period_code,subject_class,start_time,end_time,week_cycle,class_id") + .eq("teacher_timetable_id", tt_id) + .execute() + .data or [] + ) + if not slots: + return {"status": "ok", "created": 0, "updated": 0, "message": "No timetable slots found"} + + # ── 3. Resolve class_ids for slots (match by subject_class name) ────────── + class_name_to_id: Dict[str, str] = {} + subject_names = list({s["subject_class"] for s in slots if s.get("subject_class")}) + if subject_names: + try: + classes_res = ( + sb.supabase.table("classes") + .select("id,name,class_code") + .eq("institute_id", institute_id) + .eq("is_active", True) + .execute() + .data or [] + ) + for c in classes_res: + if c.get("name"): + class_name_to_id[c["name"]] = c["id"] + if c.get("class_code"): + class_name_to_id[c["class_code"]] = c["id"] + except Exception as e: + logger.warning(f"Could not resolve class names: {e}") + + # slot lookup keyed by (day_of_week, period_code, week_cycle) + # week_cycle='' means applies to both A and B weeks + slot_map: Dict[Tuple[str, str, str], Dict] = {} + for slot in slots: + key = (slot["day_of_week"], slot["period_code"], slot.get("week_cycle", "")) + slot_map[key] = slot + + # ── 4. Get all academic_periods with day + week info ────────────────────── + # Fetch academic_days and academic_weeks separately, then join in Python + days_res = ( + sb.supabase.table("academic_days") + .select("id,date,day_of_week,academic_week_id,day_type") + .eq("institute_id", institute_id) + .execute() + .data or [] + ) + weeks_res = ( + sb.supabase.table("academic_weeks") + .select("id,week_cycle") + .eq("institute_id", institute_id) + .execute() + .data or [] + ) + week_cycle_map = {w["id"]: w["week_cycle"] for w in weeks_res} + + # Only academic days get lesson periods + academic_day_map = { + d["id"]: {**d, "week_cycle": week_cycle_map.get(d["academic_week_id"], "A")} + for d in days_res + if d["day_type"] == "Academic" + } + + periods_res = ( + sb.supabase.table("academic_periods") + .select("id,academic_day_id,period_code,period_name,start_time,end_time,period_type") + .eq("institute_id", institute_id) + .execute() + .data or [] + ) + + # ── 5. Match slots to periods ───────────────────────────────────────────── + to_upsert: List[Dict] = [] + whiteboard_rows: List[Dict] = [] + + for period in periods_res: + day_info = academic_day_map.get(period["academic_day_id"]) + if not day_info: + continue + + dow = day_info["day_of_week"] + week_cycle = day_info["week_cycle"] + pcode = period["period_code"] + d = str(day_info["date"])[:10] + + # Check all matching slot patterns: exact week_cycle match or '' (both weeks) + matched_slot = ( + slot_map.get((dow, pcode, week_cycle)) + or slot_map.get((dow, pcode, "")) + ) + if not matched_slot: + continue + + subj_class = matched_slot.get("subject_class", "") + # Prefer the slot's own class_id, fall back to name-lookup + class_id = ( + matched_slot.get("class_id") + or class_name_to_id.get(subj_class) + ) + + tl_id_hint = f"tl_{period['id']}" # deterministic for neo4j_node_id + to_upsert.append({ + "academic_period_id": period["id"], + "teacher_timetable_slot_id": matched_slot["id"], + "class_id": class_id, # may be None + "teacher_id": user_id, + "institute_id": institute_id, + "date": d, + "period_code": pcode, + "week_cycle": week_cycle, + "day_of_week": dow, + "status": "planned", + "neo4j_node_id": tl_id_hint, + }) + + if not to_upsert: + return {"status": "ok", "created": 0, "updated": 0, "message": "No slot-period matches found"} + + # ── 6. Batch-upsert taught_lessons ──────────────────────────────────────── + BATCH = 100 + created = 0 + for i in range(0, len(to_upsert), BATCH): + chunk = to_upsert[i : i + BATCH] + try: + res = ( + sb.supabase.table("taught_lessons") + .upsert(chunk, on_conflict="academic_period_id,teacher_id") + .execute() + ) + created += len(res.data or []) + except Exception as e: + logger.error(f"taught_lessons upsert chunk {i}: {e}") + + # ── 7. Fetch newly-created taught_lesson ids, create whiteboard_rooms ───── + try: + tl_rows = ( + sb.supabase.table("taught_lessons") + .select("id,date,period_code,class_id") + .eq("teacher_id", user_id) + .eq("institute_id", institute_id) + .is_("whiteboard_room_id", "null") + .execute() + .data or [] + ) + + # Build whiteboard_room rows + wr_rows = [] + for tl in tl_rows: + class_name = class_name_to_id and next( + (name for name, cid in class_name_to_id.items() if cid == tl.get("class_id")), + tl.get("period_code", "") + ) + wr_rows.append({ + "user_id": user_id, + "institute_id": institute_id, + "name": f"{tl['date']} {tl['period_code']}", + "context_type": "taught_lesson", + "context_id": tl["id"], + "storage_path": f"taught_lessons/{tl['id']}", + }) + + # Batch insert whiteboard_rooms + wr_ids: Dict[str, str] = {} # context_id → room_id + for i in range(0, len(wr_rows), BATCH): + chunk = wr_rows[i : i + BATCH] + try: + res = sb.supabase.table("whiteboard_rooms").insert(chunk).execute() + for row in (res.data or []): + if row.get("context_id"): + wr_ids[row["context_id"]] = row["id"] + except Exception as e: + logger.warning(f"whiteboard_rooms batch insert: {e}") + + # Update taught_lessons with their whiteboard_room_id + for context_id, room_id in wr_ids.items(): + try: + sb.supabase.table("taught_lessons").update( + {"whiteboard_room_id": room_id} + ).eq("id", context_id).execute() + except Exception as e: + logger.warning(f"whiteboard_room_id update for {context_id}: {e}") + + rooms_created = len(wr_ids) + except Exception as e: + logger.warning(f"Whiteboard room creation failed (non-fatal): {e}") + rooms_created = 0 + + logger.info(f"Materialized {created} taught_lessons, {rooms_created} whiteboard_rooms for teacher {user_id}") + return { + "status": "ok", + "lessons_upserted": created, + "whiteboard_rooms_created": rooms_created, + "total_matches": len(to_upsert), + } + + +# ─── Lesson timeline ───────────────────────────────────────────────────────── + +@router.get("/lessons") +async def get_lessons( + week_start: Optional[str] = None, # ISO date "YYYY-MM-DD" (Monday); defaults to current week + weeks: int = 1, # how many weeks to return (max 4) + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Return taught_lessons for the teacher within a date range, grouped by date. + Defaults to the current week. + """ + user_id = credentials.get("sub", "") + institute_id = _require_institute(user_id) + sb = _sb() + + # Resolve week window + today = date.today() + if week_start: + try: + monday = datetime.strptime(week_start, "%Y-%m-%d").date() + except ValueError: + monday = today - timedelta(days=today.weekday()) + else: + monday = today - timedelta(days=today.weekday()) + + weeks = min(max(weeks, 1), 4) + friday = monday + timedelta(weeks=weeks, days=4) + + lessons = ( + sb.supabase.table("taught_lessons") + .select( + "id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id," + "class_id,academic_period_id" + ) + .eq("teacher_id", user_id) + .eq("institute_id", institute_id) + .gte("date", str(monday)) + .lte("date", str(friday)) + .order("date") + .order("period_code") + .execute() + .data or [] + ) + + # Enrich with period time and class name + period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")] + class_ids = list({l["class_id"] for l in lessons if l.get("class_id")}) + + period_map: Dict[str, Dict] = {} + class_map: Dict[str, Dict] = {} + + if period_ids: + prows = ( + sb.supabase.table("academic_periods") + .select("id,period_name,start_time,end_time") + .in_("id", period_ids) + .execute() + .data or [] + ) + period_map = {r["id"]: r for r in prows} + + if class_ids: + crows = ( + sb.supabase.table("classes") + .select("id,name,class_code,subject,year_group") + .in_("id", class_ids) + .execute() + .data or [] + ) + class_map = {r["id"]: r for r in crows} + + # Group by date + days_map: Dict[str, List[Dict]] = defaultdict(list) + for lesson in lessons: + p = period_map.get(lesson.get("academic_period_id", ""), {}) + c = class_map.get(lesson.get("class_id", ""), {}) + enriched = { + **lesson, + "period_name": p.get("period_name", lesson["period_code"]), + "start_time": p.get("start_time"), + "end_time": p.get("end_time"), + "class_name": c.get("name") or c.get("class_code"), + "subject": c.get("subject"), + "year_group": c.get("year_group"), + } + days_map[lesson["date"]].append(enriched) + + # Build ordered list of days + days_list = [] + current = monday + while current <= friday: + d_str = str(current) + days_list.append({ + "date": d_str, + "day_of_week": current.strftime("%A"), + "is_today": current == today, + "lessons": days_map.get(d_str, []), + }) + current += timedelta(days=1) + # Skip weekends + if current.weekday() >= 5: + current += timedelta(days=7 - current.weekday()) + + return { + "status": "ok", + "week_start": str(monday), + "week_end": str(friday), + "days": days_list, + "total_lessons": len(lessons), + } + + +@router.get("/lessons/{lesson_id}") +async def get_lesson( + lesson_id: str, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + sb = _sb() + + res = ( + sb.supabase.table("taught_lessons") + .select("*") + .eq("id", lesson_id) + .eq("teacher_id", user_id) + .single() + .execute() + ) + if not res.data: + raise HTTPException(status_code=404, detail="Lesson not found") + return res.data + + +@router.patch("/lessons/{lesson_id}") +async def update_lesson( + lesson_id: str, + body: UpdateLessonRequest, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """Teacher updates their own lesson content: plan, notes, status.""" + user_id = credentials.get("sub", "") + sb = _sb() + + updates: Dict[str, Any] = {} + if body.lesson_plan is not None: + updates["lesson_plan"] = body.lesson_plan + if body.notes is not None: + updates["notes"] = body.notes + if body.status is not None: + valid = {"planned", "in_progress", "completed", "cancelled", "substituted"} + if body.status not in valid: + raise HTTPException(status_code=400, detail=f"status must be one of {valid}") + updates["status"] = body.status + + if not updates: + raise HTTPException(status_code=400, detail="Nothing to update") + + updates["updated_at"] = datetime.utcnow().isoformat() + res = ( + sb.supabase.table("taught_lessons") + .update(updates) + .eq("id", lesson_id) + .eq("teacher_id", user_id) + .execute() + ) + if not res.data: + raise HTTPException(status_code=404, detail="Lesson not found or access denied") + return res.data[0] + + +# ─── Student lesson view ────────────────────────────────────────────────────── + +@router.get("/student/lessons") +async def get_student_lessons( + week_start: Optional[str] = None, + weeks: int = 1, + credentials: dict = Depends(SupabaseBearer()), +) -> Dict[str, Any]: + """ + Return taught_lessons for a student's enrolled classes for a date range. + Grouped by date, Mon-Fri only. + """ + user_id = credentials.get("sub", "") + institute_id = _resolve_institute_id(user_id) + if not institute_id: + return {"status": "error", "message": "Not linked to a school"} + sb = _sb() + + today = date.today() + if week_start: + try: + monday = datetime.strptime(week_start, "%Y-%m-%d").date() + except ValueError: + monday = today - timedelta(days=today.weekday()) + else: + monday = today - timedelta(days=today.weekday()) + + weeks = min(max(weeks, 1), 4) + friday = monday + timedelta(weeks=weeks, days=4) + + # Get student's active class memberships + memberships = ( + sb.supabase.table("class_students") + .select("class_id") + .eq("student_id", user_id) + .eq("status", "active") + .execute() + .data or [] + ) + class_ids = [m["class_id"] for m in memberships] + if not class_ids: + days_list = [] + current = monday + while current <= friday: + days_list.append({ + "date": str(current), + "day_of_week": current.strftime("%A"), + "is_today": current == today, + "lessons": [], + }) + current += timedelta(days=1) + if current.weekday() >= 5: + current += timedelta(days=7 - current.weekday()) + return {"status": "ok", "week_start": str(monday), "days": days_list, "total_lessons": 0} + + # Query taught_lessons for those classes + lessons = ( + sb.supabase.table("taught_lessons") + .select( + "id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id," + "class_id,academic_period_id,teacher_id" + ) + .in_("class_id", class_ids) + .eq("institute_id", institute_id) + .gte("date", str(monday)) + .lte("date", str(friday)) + .order("date") + .order("period_code") + .execute() + .data or [] + ) + + # Enrich with period times, class name, teacher name + period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")] + lesson_class_ids = list({l["class_id"] for l in lessons if l.get("class_id")}) + teacher_ids = list({l["teacher_id"] for l in lessons if l.get("teacher_id")}) + + period_map: Dict[str, Dict] = {} + class_map: Dict[str, Dict] = {} + teacher_map: Dict[str, Dict] = {} + + if period_ids: + prows = ( + sb.supabase.table("academic_periods") + .select("id,period_name,start_time,end_time") + .in_("id", period_ids) + .execute() + .data or [] + ) + period_map = {r["id"]: r for r in prows} + + if lesson_class_ids: + crows = ( + sb.supabase.table("classes") + .select("id,name,class_code,subject,year_group") + .in_("id", lesson_class_ids) + .execute() + .data or [] + ) + class_map = {r["id"]: r for r in crows} + + if teacher_ids: + trows = ( + sb.supabase.table("profiles") + .select("id,full_name,display_name") + .in_("id", teacher_ids) + .execute() + .data or [] + ) + teacher_map = {r["id"]: r for r in trows} + + from collections import defaultdict + days_map: Dict[str, List[Dict]] = defaultdict(list) + for lesson in lessons: + p = period_map.get(lesson.get("academic_period_id", ""), {}) + c = class_map.get(lesson.get("class_id", ""), {}) + t = teacher_map.get(lesson.get("teacher_id", ""), {}) + enriched = { + **lesson, + "period_name": p.get("period_name", lesson["period_code"]), + "start_time": p.get("start_time"), + "end_time": p.get("end_time"), + "class_name": c.get("name") or c.get("class_code"), + "subject": c.get("subject"), + "year_group": c.get("year_group"), + "teacher_name": t.get("display_name") or t.get("full_name"), + } + days_map[lesson["date"]].append(enriched) + + days_list = [] + current = monday + while current <= friday: + d_str = str(current) + days_list.append({ + "date": d_str, + "day_of_week": current.strftime("%A"), + "is_today": current == today, + "lessons": days_map.get(d_str, []), + }) + current += timedelta(days=1) + if current.weekday() >= 5: + current += timedelta(days=7 - current.weekday()) + + return { + "status": "ok", + "week_start": str(monday), + "week_end": str(friday), + "days": days_list, + "total_lessons": len(lessons), + } diff --git a/routers/database/tools/timetable_builder_router.py b/routers/database/tools/timetable_builder_router.py index fe61786..6db4a24 100644 --- a/routers/database/tools/timetable_builder_router.py +++ b/routers/database/tools/timetable_builder_router.py @@ -1,47 +1,53 @@ """ -Timetable Builder Router -Endpoints for setting up academic year structure and teacher timetable slots. +Timetable Builder Router — Supabase-first write architecture. +Supabase is the source of truth; Neo4j is a derived graph rebuildable at any time. """ import os import json +from collections import defaultdict from datetime import datetime, timedelta, date -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple from fastapi import APIRouter, Depends from pydantic import BaseModel from modules.logger_tool import initialise_logger from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.supabase.utils.client import SupabaseServiceRoleClient import modules.database.tools.neo4j_driver_tools as driver_tools logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) router = APIRouter() +# ─── Constants ──────────────────────────────────────────────────────────────── + +DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + +DAY_TYPE_LABELS: Dict[str, str] = { + "Academic": "AcademicDay", + "Holiday": "HolidayDay", + "Staff": "StaffDay", + "OffTimetable": "OffTimetableDay", +} + +PERIOD_TYPE_LABELS: Dict[str, str] = { + "lesson": "AcademicPeriod", + "break": "BreakPeriod", + "registration": "RegistrationPeriod", + "offtimetable": "OffTimetablePeriod", +} + +PERIOD_DAY_RELS: Dict[str, str] = { + "AcademicPeriod": "ACADEMIC_DAY_HAS_ACADEMIC_PERIOD", + "BreakPeriod": "ACADEMIC_DAY_HAS_BREAK_PERIOD", + "RegistrationPeriod": "ACADEMIC_DAY_HAS_REGISTRATION_PERIOD", + "OffTimetablePeriod": "ACADEMIC_DAY_HAS_ACADEMIC_PERIOD", +} + + # ─── Helpers ────────────────────────────────────────────────────────────────── -def _find_teacher_institute(user_email: str): - if not user_email: - return None, None - try: - with driver_tools.get_session(database="system") as s: - dbs = [r["name"] for r in s.run( - "SHOW DATABASES YIELD name " - "WHERE name STARTS WITH 'cc.institutes.' " - "AND NOT name ENDS WITH '.curriculum' RETURN name" - )] - for db in dbs: - try: - with driver_tools.get_session(database=db) as s: - rec = s.run( - "MATCH (t:Teacher) WHERE t.worker_email = $e " - "RETURN t.uuid_string AS uuid LIMIT 1", e=user_email - ).single() - if rec and rec["uuid"]: - return db, rec["uuid"] - except Exception: - continue - except Exception as e: - logger.warning(f"Institute lookup failed: {e}") - return None, None +def _sb() -> SupabaseServiceRoleClient: + return SupabaseServiceRoleClient() def _iso_date(s: str) -> date: @@ -49,7 +55,7 @@ def _iso_date(s: str) -> date: def _academic_weeks(term_start: date, term_end: date): - # First Monday >= term_start + """Yield (week_number, monday_date) for each Mon–Sun block that overlaps the term.""" current = term_start - timedelta(days=term_start.weekday()) if current < term_start: current += timedelta(weeks=1) @@ -60,6 +66,86 @@ def _academic_weeks(term_start: date, term_end: date): n += 1 +def _query_teacher_uuid(db: str, email: str) -> Optional[str]: + try: + with driver_tools.get_session(database=db) as s: + rec = s.run( + "MATCH (t:Teacher) WHERE t.worker_email = $e RETURN t.uuid_string AS uuid LIMIT 1", + e=email, + ).single() + return rec["uuid"] if rec else None + except Exception: + return None + + +def _scan_institute_dbs(user_email: str) -> Tuple[Optional[str], Optional[str]]: + """Fallback: scan all institute DBs for a Teacher node with matching email.""" + if not user_email: + return None, None + try: + with driver_tools.get_session(database="system") as s: + dbs = [ + r["name"] for r in s.run( + "SHOW DATABASES YIELD name " + "WHERE name STARTS WITH 'cc.institutes.' " + "AND NOT name ENDS WITH '.curriculum' RETURN name" + ) + ] + for db in dbs: + try: + with driver_tools.get_session(database=db) as s: + rec = s.run( + "MATCH (t:Teacher) WHERE t.worker_email = $e " + "RETURN t.uuid_string AS uuid LIMIT 1", + e=user_email, + ).single() + if rec and rec["uuid"]: + return db, rec["uuid"] + except Exception: + continue + except Exception as e: + logger.warning(f"Institute DB scan failed: {e}") + return None, None + + +def _resolve_institute( + user_id: str, user_email: str +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid). + Supabase-first lookup with Neo4j email-scan fallback. + """ + # Fast path: profiles.school_id → institutes.neo4j_uuid_string + try: + sb = _sb() + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + school_id = (p.data or {}).get("school_id") + if school_id: + i = sb.supabase.table("institutes").select("id,neo4j_uuid_string").eq("id", str(school_id)).single().execute() + inst = i.data or {} + neo4j_uuid = inst.get("neo4j_uuid_string") + if neo4j_uuid: + db = f"cc.institutes.{neo4j_uuid}" + teacher_uuid = _query_teacher_uuid(db, user_email) + return str(school_id), db, teacher_uuid + except Exception as e: + logger.warning(f"Supabase-first institute resolve failed: {e}") + + # Fallback: scan Neo4j + db, teacher_uuid = _scan_institute_dbs(user_email) + supabase_id: Optional[str] = None + if db: + try: + sb = _sb() + p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() + sid = (p.data or {}).get("school_id") + if sid: + supabase_id = str(sid) + except Exception: + pass + return supabase_id, db, teacher_uuid + + # ─── Request models ─────────────────────────────────────────────────────────── class TermInput(BaseModel): @@ -67,19 +153,45 @@ class TermInput(BaseModel): term_number: int start_date: str end_date: str + notes: Optional[str] = None + class PeriodInput(BaseModel): code: str name: str start_time: str end_time: str - period_type: str # lesson | break | registration + period_type: str # lesson | break | registration | offtimetable + + +class DayInput(BaseModel): + date: str # "2025-09-01" + day_type: str = "Academic" # Academic | Holiday | Staff | OffTimetable + week_cycle: str = "A" + excluded_period_codes: Optional[List[str]] = None + + +class TermBreakInput(BaseModel): + name: str # "Christmas Break" + start_date: str # "2025-12-20" + end_date: str # "2026-01-05" + + +class WeekCycleOverride(BaseModel): + term_number: int + week_number: int # 1-based, within the term + cycle: str # "A" | "B" + class TimetableSetupRequest(BaseModel): year_start: str year_end: str terms: List[TermInput] periods: List[PeriodInput] + days: Optional[List[DayInput]] = None # None = auto-generate Mon–Fri as Academic + term_breaks: Optional[List[TermBreakInput]] = None + week_cycles: Optional[List[WeekCycleOverride]] = None + class SlotInput(BaseModel): day_of_week: str @@ -87,27 +199,664 @@ class SlotInput(BaseModel): subject_class: str start_time: str end_time: str + week_cycle: str = "" # '' = both A/B weeks; 'A' or 'B' = specific week + class SlotsRequest(BaseModel): timetable_id: str slots: List[SlotInput] +# ─── Supabase write helpers ─────────────────────────────────────────────────── + +def _sb_upsert_timetable( + sb: SupabaseServiceRoleClient, + institute_id: str, + year_start: date, + year_end: date, + year_label: str, + school_tt_id: str, + periods: List[PeriodInput], +) -> str: + res = sb.supabase.table("school_timetables").upsert( + { + "institute_id": institute_id, + "year_label": year_label, + "start_date": str(year_start), + "end_date": str(year_end), + "periods_template": [p.dict() for p in periods], + "neo4j_node_id": school_tt_id, + }, + on_conflict="institute_id,year_label", + ).execute() + return (res.data or [{}])[0].get("id", "") + + +def _sb_upsert_academic_year( + sb: SupabaseServiceRoleClient, + stt_sb_id: str, + institute_id: str, + year_label: str, + ay_id: str, +) -> str: + res = sb.supabase.table("academic_years").upsert( + { + "school_timetable_id": stt_sb_id, + "institute_id": institute_id, + "year_label": year_label, + "neo4j_node_id": ay_id, + }, + on_conflict="school_timetable_id,year_label", + ).execute() + return (res.data or [{}])[0].get("id", "") + + +def _sb_upsert_term_breaks( + sb: SupabaseServiceRoleClient, + institute_id: str, + stt_sb_id: str, + term_breaks: List[TermBreakInput], +) -> None: + for tb in term_breaks: + sb.supabase.table("academic_term_breaks").upsert( + { + "school_timetable_id": stt_sb_id, + "institute_id": institute_id, + "break_name": tb.name, + "start_date": tb.start_date, + "end_date": tb.end_date, + }, + on_conflict="school_timetable_id,break_name", + ).execute() + + +def _sb_upsert_terms_weeks_days( + sb: SupabaseServiceRoleClient, + ay_sb_id: str, + institute_id: str, + terms: List[TermInput], + year_start: date, + days_by_date: Dict[str, DayInput], + week_cycles_map: Optional[Dict[Tuple[int, int], str]] = None, +) -> None: + academic_day_number = 0 + for term in terms: + t_id = f"term_{year_start.year}_{term.term_number}" + t_start = _iso_date(term.start_date) + t_end = _iso_date(term.end_date) + + t_upsert: Dict[str, Any] = { + "academic_year_id": ay_sb_id, + "institute_id": institute_id, + "term_name": term.name, + "term_number": term.term_number, + "start_date": term.start_date, + "end_date": term.end_date, + "neo4j_node_id": t_id, + } + if term.notes is not None: + t_upsert["notes"] = term.notes + + t_res = sb.supabase.table("academic_terms").upsert( + t_upsert, on_conflict="academic_year_id,term_number", + ).execute() + t_sb_id = (t_res.data or [{}])[0].get("id", "") + + for wn, wstart in _academic_weeks(t_start, t_end): + w_id = f"week_{t_id}_{wn}" + # Use caller-provided cycle if given, else default alternating A/B within term + if week_cycles_map and (term.term_number, wn) in week_cycles_map: + week_cycle = week_cycles_map[(term.term_number, wn)] + else: + week_cycle = "A" if wn % 2 == 1 else "B" + + w_res = sb.supabase.table("academic_weeks").upsert( + { + "academic_term_id": t_sb_id, + "institute_id": institute_id, + "week_number": wn, + "start_date": wstart.isoformat(), + "week_cycle": week_cycle, + "neo4j_node_id": w_id, + }, + on_conflict="academic_term_id,week_number", + ).execute() + w_sb_id = (w_res.data or [{}])[0].get("id", "") + + for day_offset in range(5): # Mon–Fri + d = wstart + timedelta(days=day_offset) + if d < t_start or d > t_end: + continue + date_iso = d.isoformat() + day_input = days_by_date.get(date_iso) + day_type = day_input.day_type if day_input else "Academic" + excl = (day_input.excluded_period_codes or []) if day_input else [] + + if day_type == "Academic": + academic_day_number += 1 + adn: Optional[int] = academic_day_number + else: + adn = None + + d_id = f"day_{w_id}_{date_iso}" + sb.supabase.table("academic_days").upsert( + { + "academic_week_id": w_sb_id, + "academic_term_id": t_sb_id, + "institute_id": institute_id, + "date": date_iso, + "day_of_week": DAY_NAMES[d.weekday()], + "day_type": day_type, + "academic_day_number": adn, + "excluded_period_codes": excl, + "neo4j_node_id": d_id, + }, + on_conflict="institute_id,date", + ).execute() + + +def _sb_upsert_teacher_timetable( + sb: SupabaseServiceRoleClient, + profile_id: str, + institute_id: str, + stt_sb_id: str, + year_start: date, + year_end: date, + teacher_tt_id: str, +) -> str: + res = sb.supabase.table("teacher_timetables").upsert( + { + "profile_id": profile_id, + "institute_id": institute_id, + "school_timetable_id": stt_sb_id, + "start_date": str(year_start), + "end_date": str(year_end), + "neo4j_node_id": teacher_tt_id, + }, + on_conflict="profile_id,school_timetable_id", + ).execute() + return (res.data or [{}])[0].get("id", "") + + +# ─── Neo4j build from Supabase ──────────────────────────────────────────────── + +def _build_neo4j_from_supabase(institute_id: str, institute_db: str) -> Dict[str, int]: + """ + Read all academic calendar data from Supabase for institute_id and + create/merge the full Neo4j node graph in institute_db. + Returns counts of nodes created. + """ + sb = _sb() + + stt_rows = sb.supabase.table("school_timetables").select("*").eq("institute_id", institute_id).execute().data or [] + if not stt_rows: + raise ValueError(f"No school_timetable in Supabase for institute {institute_id}") + stt = stt_rows[0] + + ay_rows = sb.supabase.table("academic_years").select("*").eq("school_timetable_id", stt["id"]).execute().data or [] + if not ay_rows: + raise ValueError("No academic_year found for this timetable") + ay = ay_rows[0] + + terms = sb.supabase.table("academic_terms").select("*").eq("academic_year_id", ay["id"]).order("term_number").execute().data or [] + term_ids = [t["id"] for t in terms] + + weeks: List[Dict] = [] + if term_ids: + weeks = sb.supabase.table("academic_weeks").select("*").in_("academic_term_id", term_ids).order("start_date").execute().data or [] + + days = sb.supabase.table("academic_days").select("*").eq("institute_id", institute_id).order("date").execute().data or [] + + weeks_by_term: Dict[str, List[Dict]] = defaultdict(list) + for w in weeks: + weeks_by_term[w["academic_term_id"]].append(w) + + days_by_week: Dict[str, List[Dict]] = defaultdict(list) + for d in days: + days_by_week[d["academic_week_id"]].append(d) + + periods_template: List[Dict] = stt.get("periods_template") or [] + counts = {"terms": 0, "weeks": 0, "days": 0, "periods": 0} + + with driver_tools.get_session(database=institute_db) as s: + # SchoolTimetable + s.run(""" + MERGE (tt:SchoolTimetable {uuid_string: $id}) + SET tt.school_timetable_id = $id, + tt.start_date = date($start), tt.end_date = date($end), + tt.node_storage_path = $path, + tt.periods_template = $periods + WITH tt MATCH (sch:School) + MERGE (sch)-[:HAS_TIMETABLE]->(tt) + """, + id=stt["neo4j_node_id"], + start=str(stt["start_date"]), + end=str(stt["end_date"]), + path=f"timetable/{stt['neo4j_node_id']}", + periods=json.dumps(periods_template), + ) + + # AcademicYear + s.run(""" + MERGE (ay:AcademicYear {uuid_string: $id}) + SET ay.year = $year, ay.node_storage_path = $path + WITH ay MATCH (tt:SchoolTimetable {uuid_string: $tt_id}) + MERGE (tt)-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay) + """, + id=ay["neo4j_node_id"], + year=ay["year_label"], + path=f"timetable/{ay['neo4j_node_id']}", + tt_id=stt["neo4j_node_id"], + ) + + for term in terms: + s.run(""" + MERGE (t:AcademicTerm {uuid_string: $id}) + SET t.term_name = $name, t.term_number = $num, + t.start_date = date($start), t.end_date = date($end), + t.node_storage_path = $path + WITH t MATCH (ay:AcademicYear {uuid_string: $ay_id}) + MERGE (ay)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t) + """, + id=term["neo4j_node_id"], + name=term["term_name"], + num=str(term["term_number"]), + start=str(term["start_date"]), + end=str(term["end_date"]), + path=f"timetable/{term['neo4j_node_id']}", + ay_id=ay["neo4j_node_id"], + ) + counts["terms"] += 1 + + for week in weeks_by_term[term["id"]]: + s.run(""" + MERGE (w:AcademicWeek {uuid_string: $id}) + SET w.academic_week_number = $num, + w.start_date = date($start), + w.week_cycle = $cycle, + w.week_type = 'academic', + w.node_storage_path = $path + WITH w MATCH (t:AcademicTerm {uuid_string: $t_id}) + MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w) + """, + id=week["neo4j_node_id"], + num=str(week["week_number"]), + start=str(week["start_date"]), + cycle=week["week_cycle"], + path=f"timetable/{week['neo4j_node_id']}", + t_id=term["neo4j_node_id"], + ) + counts["weeks"] += 1 + + for day in days_by_week[week["id"]]: + label = DAY_TYPE_LABELS.get(day["day_type"], "AcademicDay") + s.run( + f""" + MERGE (d:{label} {{uuid_string: $id}}) + SET d.date = date($date), d.day_of_week = $dow, + d.day_type = $dtype, + d.academic_day = $adn, + d.node_storage_path = $path + WITH d MATCH (w:AcademicWeek {{uuid_string: $w_id}}) + MERGE (w)-[:ACADEMIC_WEEK_HAS_ACADEMIC_DAY]->(d) + WITH d MATCH (t:AcademicTerm {{uuid_string: $t_id}}) + MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_DAY]->(d) + """, + id=day["neo4j_node_id"], + date=str(day["date"]), + dow=day["day_of_week"], + dtype=day["day_type"], + adn=str(day.get("academic_day_number") or ""), + path=f"timetable/{day['neo4j_node_id']}", + w_id=week["neo4j_node_id"], + t_id=term["neo4j_node_id"], + ) + counts["days"] += 1 + + # Period nodes for Academic days only + if day["day_type"] == "Academic": + excluded = set(day.get("excluded_period_codes") or []) + for p in periods_template: + if p.get("code") in excluded: + continue + p_label = PERIOD_TYPE_LABELS.get( + (p.get("period_type") or "lesson").lower(), "AcademicPeriod" + ) + rel = PERIOD_DAY_RELS.get(p_label, "ACADEMIC_DAY_HAS_ACADEMIC_PERIOD") + p_id = f"{day['neo4j_node_id']}_{p['code']}" + s.run( + f""" + MERGE (p:{p_label} {{uuid_string: $id}}) + SET p.period_code = $code, p.name = $name, + p.start_time = $start, p.end_time = $end, + p.node_storage_path = $path + WITH p MATCH (d:{label} {{uuid_string: $d_id}}) + MERGE (d)-[:{rel}]->(p) + """, + id=p_id, + code=p["code"], + name=p["name"], + start=p["start_time"], + end=p["end_time"], + path=f"timetable/{p_id}", + d_id=day["neo4j_node_id"], + ) + counts["periods"] += 1 + + # ── Teacher timetables + taught lessons (best-effort — may be empty on first run) + try: + tt_counts = _sync_teacher_timetables_to_neo4j(institute_id, institute_db, sb) + counts.update(tt_counts) + except Exception as e: + logger.warning(f"TeacherTimetable sync skipped: {e}") + try: + counts["taught_lessons"] = _sync_taught_lessons_to_neo4j(institute_id, institute_db, sb) + except Exception as e: + logger.warning(f"TaughtLesson sync skipped: {e}") + + return counts + + +def _sync_teacher_timetables_to_neo4j( + institute_id: str, + institute_db: str, + sb: SupabaseServiceRoleClient, +) -> Dict[str, int]: + """ + Rebuild TeacherTimetable and TimetableSlot Neo4j nodes from Supabase + for the given institute. Safe to re-run (MERGE). + """ + counts: Dict[str, int] = {"teacher_timetables": 0, "slots": 0} + + tt_rows = ( + sb.supabase.table("teacher_timetables") + .select("id,profile_id,neo4j_node_id,start_date,end_date") + .eq("institute_id", institute_id) + .execute() + .data or [] + ) + if not tt_rows: + return counts + + profile_ids = list({t["profile_id"] for t in tt_rows if t.get("profile_id")}) + email_map: Dict[str, str] = {} + if profile_ids: + prows = ( + sb.supabase.table("profiles") + .select("id,email") + .in_("id", profile_ids) + .execute() + .data or [] + ) + email_map = {p["id"]: p["email"] for p in prows} + + tt_ids = [t["id"] for t in tt_rows] + all_slots: Dict[str, List[Dict]] = defaultdict(list) + for i in range(0, len(tt_ids), 100): + chunk = tt_ids[i : i + 100] + slot_rows = ( + sb.supabase.table("teacher_timetable_slots") + .select("*") + .in_("teacher_timetable_id", chunk) + .execute() + .data or [] + ) + for slot in slot_rows: + all_slots[slot["teacher_timetable_id"]].append(slot) + + with driver_tools.get_session(database=institute_db) as s: + for tt in tt_rows: + tt_neo4j_id = tt.get("neo4j_node_id") + if not tt_neo4j_id: + continue + + teacher_email = email_map.get(tt["profile_id"], "") + teacher_uuid = _query_teacher_uuid(institute_db, teacher_email) if teacher_email else None + + s.run(""" + MERGE (tt:TeacherTimetable {uuid_string: $id}) + SET tt.teacher_timetable_id = $id, + tt.start_date = date($start), + tt.end_date = date($end), + tt.node_storage_path = $path + """, + id=tt_neo4j_id, + start=str(tt["start_date"]), + end=str(tt["end_date"]), + path=f"timetable/{tt_neo4j_id}", + ) + counts["teacher_timetables"] += 1 + + if teacher_uuid: + s.run(""" + MATCH (t:Teacher {uuid_string: $tu}) + MATCH (tt:TeacherTimetable {uuid_string: $id}) + MERGE (t)-[:HAS_TIMETABLE]->(tt) + """, tu=teacher_uuid, id=tt_neo4j_id) + + for slot in all_slots.get(tt["id"], []): + slot_neo4j_id = ( + slot.get("neo4j_node_id") + or f"slot_{tt_neo4j_id}_{slot['day_of_week']}_{slot['period_code']}_{slot.get('week_cycle', '')}" + ) + s.run(""" + MERGE (sl:TimetableSlot {uuid_string: $id}) + SET sl.day_of_week = $day, sl.period_code = $code, + sl.subject_class = $cls, sl.start_time = $start, + sl.end_time = $end, sl.week_cycle = $wc, + sl.node_storage_path = $path + WITH sl MATCH (tt:TeacherTimetable {uuid_string: $tt_id}) + MERGE (tt)-[:HAS_TIMETABLE_SLOT]->(sl) + """, + id=slot_neo4j_id, + day=slot["day_of_week"], + code=slot["period_code"], + cls=slot.get("subject_class", ""), + start=slot.get("start_time", ""), + end=slot.get("end_time", ""), + wc=slot.get("week_cycle", ""), + path=f"timetable/{slot_neo4j_id}", + tt_id=tt_neo4j_id, + ) + counts["slots"] += 1 + + return counts + + +def _sync_taught_lessons_to_neo4j( + institute_id: str, + institute_db: str, + sb: SupabaseServiceRoleClient, +) -> int: + """ + Create/update TaughtLesson Neo4j nodes from Supabase taught_lessons. + Links each node to its AcademicPeriod and TeacherTimetable. Safe to re-run. + Returns count of lessons merged. + """ + lessons = ( + sb.supabase.table("taught_lessons") + .select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status") + .eq("institute_id", institute_id) + .execute() + .data or [] + ) + if not lessons: + return 0 + + # academic_period UUID → neo4j_node_id + period_ids = list({l["academic_period_id"] for l in lessons if l.get("academic_period_id")}) + period_neo4j_map: Dict[str, str] = {} + for i in range(0, len(period_ids), 100): + chunk = period_ids[i : i + 100] + prows = ( + sb.supabase.table("academic_periods") + .select("id,neo4j_node_id") + .in_("id", chunk) + .execute() + .data or [] + ) + for p in prows: + if p.get("neo4j_node_id"): + period_neo4j_map[p["id"]] = p["neo4j_node_id"] + + # teacher profile_id → teacher_timetable neo4j_node_id + teacher_ids = list({l["teacher_id"] for l in lessons if l.get("teacher_id")}) + teacher_tt_map: Dict[str, str] = {} + for i in range(0, len(teacher_ids), 100): + chunk = teacher_ids[i : i + 100] + ttrows = ( + sb.supabase.table("teacher_timetables") + .select("profile_id,neo4j_node_id") + .eq("institute_id", institute_id) + .in_("profile_id", chunk) + .execute() + .data or [] + ) + for tt in ttrows: + if tt.get("neo4j_node_id"): + teacher_tt_map[tt["profile_id"]] = tt["neo4j_node_id"] + + count = 0 + with driver_tools.get_session(database=institute_db) as s: + for lesson in lessons: + tl_id = lesson.get("neo4j_node_id") or f"tl_{lesson['id']}" + ap_id = period_neo4j_map.get(lesson.get("academic_period_id", "")) + tt_id = teacher_tt_map.get(lesson.get("teacher_id", "")) + + s.run(""" + MERGE (tl:TaughtLesson {uuid_string: $id}) + SET tl.date = date($date), tl.period_code = $pcode, + tl.week_cycle = $wc, tl.day_of_week = $dow, + tl.status = $status, + tl.node_storage_path = $path + """, + id=tl_id, + date=str(lesson["date"]), + pcode=lesson["period_code"], + wc=lesson.get("week_cycle", ""), + dow=lesson.get("day_of_week", ""), + status=lesson.get("status", "planned"), + path=f"taught_lessons/{tl_id}", + ) + count += 1 + + if ap_id: + s.run(""" + MATCH (ap:AcademicPeriod {uuid_string: $ap_id}) + MATCH (tl:TaughtLesson {uuid_string: $tl_id}) + MERGE (ap)-[:ACADEMIC_PERIOD_HAS_TAUGHT_LESSON]->(tl) + """, ap_id=ap_id, tl_id=tl_id) + + if tt_id: + s.run(""" + MATCH (tt:TeacherTimetable {uuid_string: $tt_id}) + MATCH (tl:TaughtLesson {uuid_string: $tl_id}) + MERGE (tt)-[:TEACHER_TIMETABLE_HAS_TAUGHT_LESSON]->(tl) + """, tt_id=tt_id, tl_id=tl_id) + + return count + + +def _write_neo4j_direct( + institute_db: str, + school_tt_id: str, + ay_id: str, + body: TimetableSetupRequest, + year_start: date, + year_label: str, + teacher_uuid: Optional[str], + teacher_tt_id: Optional[str], +) -> None: + """Fallback: write Neo4j directly when no Supabase institute_id is available.""" + with driver_tools.get_session(database=institute_db) as s: + s.run(""" + MERGE (tt:SchoolTimetable {uuid_string: $id}) + SET tt.school_timetable_id = $id, + tt.start_date = date($start), tt.end_date = date($end), + tt.node_storage_path = $path, tt.periods_template = $periods + WITH tt MATCH (sch:School) MERGE (sch)-[:HAS_TIMETABLE]->(tt) + """, + id=school_tt_id, + start=body.year_start, + end=body.year_end, + path=f"timetable/{school_tt_id}", + periods=json.dumps([p.dict() for p in body.periods]), + ) + s.run(""" + MERGE (ay:AcademicYear {uuid_string: $id}) + SET ay.year = $year, ay.node_storage_path = $path + WITH ay MATCH (tt:SchoolTimetable {uuid_string: $tt_id}) + MERGE (tt)-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay) + """, + id=ay_id, year=year_label, path=f"timetable/{ay_id}", tt_id=school_tt_id, + ) + for term in body.terms: + t_id = f"term_{year_start.year}_{term.term_number}" + t_start = _iso_date(term.start_date) + t_end = _iso_date(term.end_date) + s.run(""" + MERGE (t:AcademicTerm {uuid_string: $id}) + SET t.term_name = $name, t.term_number = $num, + t.start_date = date($start), t.end_date = date($end), + t.node_storage_path = $path + WITH t MATCH (ay:AcademicYear {uuid_string: $ay_id}) + MERGE (ay)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t) + """, + id=t_id, name=term.name, num=str(term.term_number), + start=term.start_date, end=term.end_date, + path=f"timetable/{t_id}", ay_id=ay_id, + ) + for wn, wstart in _academic_weeks(t_start, t_end): + w_id = f"week_{t_id}_{wn}" + s.run(""" + MERGE (w:AcademicWeek {uuid_string: $id}) + SET w.academic_week_number = $num, w.start_date = date($start), + w.week_type = 'academic', w.week_cycle = $cycle, + w.node_storage_path = $path + WITH w MATCH (t:AcademicTerm {uuid_string: $t_id}) + MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w) + """, + id=w_id, num=str(wn), start=wstart.isoformat(), + cycle="A" if wn % 2 == 1 else "B", + path=f"timetable/{w_id}", t_id=t_id, + ) + if teacher_uuid and teacher_tt_id: + s.run(""" + MERGE (tt:TeacherTimetable {uuid_string: $id}) + SET tt.teacher_timetable_id = $id, tt.start_date = date($start), + tt.end_date = date($end), tt.node_storage_path = $path + WITH tt MATCH (teacher:Teacher {uuid_string: $t_uuid}) + MERGE (teacher)-[:HAS_TIMETABLE]->(tt) + WITH tt MATCH (st:SchoolTimetable {uuid_string: $st_id}) + MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st) + """, + id=teacher_tt_id, start=body.year_start, end=body.year_end, + path=f"timetable/{teacher_tt_id}", + t_uuid=teacher_uuid, st_id=school_tt_id, + ) + + # ─── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/status") async def get_timetable_status(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: """Check whether the teacher has a timetable set up.""" + user_id = credentials.get("sub", "") user_email = credentials.get("email", "") - institute_db, teacher_uuid = _find_teacher_institute(user_email) + _, institute_db, teacher_uuid = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "no_school"} try: with driver_tools.get_session(database=institute_db) as s: - has_tt = s.run( - "MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) " - "RETURN tt.uuid_string AS id LIMIT 1", u=teacher_uuid - ).single() + has_tt = ( + s.run( + "MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) " + "RETURN tt.uuid_string AS id LIMIT 1", + u=teacher_uuid, + ).single() + if teacher_uuid + else None + ) has_school_tt = s.run( "MATCH (st:SchoolTimetable) RETURN st.uuid_string AS id LIMIT 1" ).single() @@ -125,27 +874,17 @@ async def get_timetable_status(credentials: dict = Depends(SupabaseBearer())) -> @router.post("/setup") async def setup_timetable( body: TimetableSetupRequest, - credentials: dict = Depends(SupabaseBearer()) + credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: - """Create academic year/term/week structure + TeacherTimetable in the institute DB.""" - user_email = credentials.get("email", "") + """ + Create school timetable structure. + Writes to Supabase first (source of truth), then builds Neo4j from Supabase. + Accepts optional days[] for AcademicDay nodes; auto-generates Mon–Fri if omitted. + Accepts optional term_breaks[] and week_cycles[] overrides. + """ user_id = credentials.get("sub", "") - institute_db, teacher_uuid = _find_teacher_institute(user_email) - - # school_admin may have no Teacher node — fall back to Supabase for institute_db - if not institute_db and user_id: - try: - from modules.database.supabase.utils.client import SupabaseServiceRoleClient - _sb = SupabaseServiceRoleClient() - _p = _sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() - _sid = (_p.data or {}).get("school_id") - if _sid: - _i = _sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", _sid).single().execute() - _uuid = (_i.data or {}).get("neo4j_uuid_string") - if _uuid: - institute_db = f"cc.institutes.{_uuid}" - except Exception as _e: - logger.warning(f"Supabase institute fallback failed: {_e}") + user_email = credentials.get("email", "") + institute_id, institute_db, teacher_uuid = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "error", "message": "Institute database not found"} @@ -155,128 +894,209 @@ async def setup_timetable( year_label = f"{year_start.year}-{year_end.year}" school_tt_id = f"school_tt_{year_start.year}_{year_end.year}" ay_id = f"academic_year_{year_start.year}_{year_end.year}" + teacher_tt_id = f"teacher_tt_{teacher_uuid}_{year_start.year}" if teacher_uuid else None + + days_by_date: Dict[str, DayInput] = {} + if body.days: + for d in body.days: + days_by_date[d.date] = d + + week_cycles_map: Optional[Dict[Tuple[int, int], str]] = None + if body.week_cycles: + week_cycles_map = {(wc.term_number, wc.week_number): wc.cycle for wc in body.week_cycles} + + # ── 1. Supabase writes ──────────────────────────────────────────────────── + stt_sb_id: Optional[str] = None + if not institute_id: + logger.warning("No Supabase institute_id — skipping Supabase writes, falling back to direct Neo4j") + else: + try: + sb = _sb() + stt_sb_id = _sb_upsert_timetable( + sb, institute_id, year_start, year_end, year_label, school_tt_id, body.periods + ) + ay_sb_id = _sb_upsert_academic_year(sb, stt_sb_id, institute_id, year_label, ay_id) + _sb_upsert_terms_weeks_days( + sb, ay_sb_id, institute_id, body.terms, year_start, days_by_date, week_cycles_map + ) + if body.term_breaks: + _sb_upsert_term_breaks(sb, institute_id, stt_sb_id, body.term_breaks) + if teacher_uuid and teacher_tt_id: + _sb_upsert_teacher_timetable( + sb, user_id, institute_id, stt_sb_id, year_start, year_end, teacher_tt_id + ) + logger.info(f"Supabase writes complete for institute {institute_id}") + except Exception as e: + logger.error(f"Supabase write failed: {e}") + return {"status": "error", "message": f"Supabase write failed: {e}"} + + # ── 2. Build Neo4j ──────────────────────────────────────────────────────── + if institute_id and stt_sb_id: + try: + counts = _build_neo4j_from_supabase(institute_id, institute_db) + logger.info(f"Neo4j build complete from Supabase: {counts}") + except Exception as e: + logger.error(f"Neo4j build from Supabase failed: {e}") + return {"status": "error", "message": f"Neo4j build failed: {e}"} + else: + try: + _write_neo4j_direct( + institute_db, school_tt_id, ay_id, body, + year_start, year_label, teacher_uuid, teacher_tt_id, + ) + except Exception as e: + logger.error(f"Neo4j direct write failed: {e}") + return {"status": "error", "message": str(e)} + + return { + "status": "ok", + "timetable_id": teacher_tt_id, + "school_timetable_id": school_tt_id, + "institute_db": institute_db, + } + + +@router.post("/rebuild-neo4j") +async def rebuild_neo4j(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: + """Rebuild all Neo4j timetable nodes from Supabase source of truth.""" + user_id = credentials.get("sub", "") + user_email = credentials.get("email", "") + institute_id, institute_db, _ = _resolve_institute(user_id, user_email) + if not institute_id or not institute_db: + return {"status": "error", "message": "Could not resolve institute"} try: - with driver_tools.get_session(database=institute_db) as s: - # SchoolTimetable - s.run(""" - MERGE (tt:SchoolTimetable {uuid_string: $id}) - SET tt.school_timetable_id = $id, - tt.start_date = date($start), tt.end_date = date($end), - tt.node_storage_path = $path, - tt.periods_template = $periods - WITH tt - MATCH (sch:School) - MERGE (sch)-[:HAS_TIMETABLE]->(tt) - """, id=school_tt_id, start=body.year_start, end=body.year_end, - path=f"timetable/{school_tt_id}", - periods=json.dumps([p.dict() for p in body.periods])) - - # AcademicYear - s.run(""" - MERGE (ay:AcademicYear {uuid_string: $id}) - SET ay.year = $year, ay.node_storage_path = $path - WITH ay - MATCH (tt:SchoolTimetable {uuid_string: $tt_id}) - MERGE (tt)-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay) - """, id=ay_id, year=year_label, path=f"timetable/{ay_id}", tt_id=school_tt_id) - - # Terms + weeks - for term in body.terms: - t_id = f"term_{year_start.year}_{term.term_number}" - t_start = _iso_date(term.start_date) - t_end = _iso_date(term.end_date) - s.run(""" - MERGE (t:AcademicTerm {uuid_string: $id}) - SET t.term_name = $name, t.term_number = $num, - t.start_date = date($start), t.end_date = date($end), - t.node_storage_path = $path - WITH t - MATCH (ay:AcademicYear {uuid_string: $ay_id}) - MERGE (ay)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t) - """, id=t_id, name=term.name, num=str(term.term_number), - start=term.start_date, end=term.end_date, - path=f"timetable/{t_id}", ay_id=ay_id) - - for wn, wstart in _academic_weeks(t_start, t_end): - w_id = f"week_{t_id}_{wn}" - s.run(""" - MERGE (w:AcademicWeek {uuid_string: $id}) - SET w.academic_week_number = $num, w.start_date = date($start), - w.week_type = 'academic', w.node_storage_path = $path - WITH w - MATCH (t:AcademicTerm {uuid_string: $t_id}) - MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w) - """, id=w_id, num=str(wn), start=wstart.isoformat(), - path=f"timetable/{w_id}", t_id=t_id) - - # TeacherTimetable — only if user has a Teacher node - if teacher_uuid: - teacher_tt_id = f"teacher_tt_{teacher_uuid}_{year_start.year}" - s.run(""" - MERGE (tt:TeacherTimetable {uuid_string: $id}) - SET tt.teacher_timetable_id = $id, - tt.start_date = date($start), tt.end_date = date($end), - tt.node_storage_path = $path - WITH tt - MATCH (teacher:Teacher {uuid_string: $t_uuid}) - MERGE (teacher)-[:HAS_TIMETABLE]->(tt) - WITH tt - MATCH (st:SchoolTimetable {uuid_string: $st_id}) - MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st) - """, id=teacher_tt_id, start=body.year_start, end=body.year_end, - path=f"timetable/{teacher_tt_id}", - t_uuid=teacher_uuid, st_id=school_tt_id) - else: - teacher_tt_id = None - - return { - "status": "ok", - "timetable_id": teacher_tt_id, - "school_timetable_id": school_tt_id, - "institute_db": institute_db, - } + counts = _build_neo4j_from_supabase(institute_id, institute_db) + return {"status": "ok", "rebuilt": counts, "institute_db": institute_db} except Exception as e: - logger.error(f"Timetable setup failed: {e}") + logger.error(f"Neo4j rebuild failed: {e}") return {"status": "error", "message": str(e)} +@router.post("/materialize-periods") +async def materialize_periods(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: + """ + Materialize academic_periods rows from academic_days × periods_template. + Creates one row per period per academic day (day_type='Academic'), + respecting excluded_period_codes per day. Safe to re-run (upserts). + """ + user_id = credentials.get("sub", "") + user_email = credentials.get("email", "") + institute_id, _, _ = _resolve_institute(user_id, user_email) + if not institute_id: + return {"status": "error", "message": "Could not resolve institute"} + + sb = _sb() + + stt_rows = ( + sb.supabase.table("school_timetables") + .select("id,periods_template") + .eq("institute_id", institute_id) + .limit(1) + .execute() + .data or [] + ) + if not stt_rows: + return {"status": "error", "message": "No school timetable found for this institute"} + stt = stt_rows[0] + periods_template: List[Dict] = stt.get("periods_template") or [] + if not periods_template: + return {"status": "error", "message": "No periods_template defined on school timetable"} + + days = ( + sb.supabase.table("academic_days") + .select("id,neo4j_node_id,day_type,excluded_period_codes") + .eq("institute_id", institute_id) + .eq("day_type", "Academic") + .execute() + .data or [] + ) + + created = 0 + skipped = 0 + errors = 0 + for day in days: + excluded = set(day.get("excluded_period_codes") or []) + for p in periods_template: + if p.get("code") in excluded: + skipped += 1 + continue + p_neo4j_id = f"{day['neo4j_node_id']}_{p['code']}" + try: + sb.supabase.table("academic_periods").upsert( + { + "academic_day_id": day["id"], + "institute_id": institute_id, + "period_code": p["code"], + "period_name": p["name"], + "period_type": p.get("period_type", "lesson"), + "start_time": p["start_time"], + "end_time": p["end_time"], + "neo4j_node_id": p_neo4j_id, + }, + on_conflict="academic_day_id,period_code", + ).execute() + created += 1 + except Exception as e: + logger.error(f"Period upsert failed for day {day['id']} period {p['code']}: {e}") + errors += 1 + + logger.info(f"Materialized {created} periods, skipped {skipped}, errors {errors} for institute {institute_id}") + return { + "status": "ok", + "created": created, + "skipped": skipped, + "errors": errors, + "academic_days": len(days), + } + + @router.get("/slots") async def get_timetable_slots(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: + user_id = credentials.get("sub", "") user_email = credentials.get("email", "") - institute_db, teacher_uuid = _find_teacher_institute(user_email) + institute_id, institute_db, _ = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "no_school", "slots": [], "periods": []} try: - with driver_tools.get_session(database=institute_db) as s: - tt_rec = s.run( - "MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) " - "RETURN tt.uuid_string AS id LIMIT 1", u=teacher_uuid - ).single() - if not tt_rec: - return {"status": "empty", "slots": [], "periods": []} - tt_id = tt_rec["id"] + sb = _sb() + tt_rows = ( + sb.supabase.table("teacher_timetables") + .select("id,neo4j_node_id") + .eq("profile_id", user_id) + .limit(1) + .execute() + .data or [] + ) + if not tt_rows: + return {"status": "empty", "slots": [], "periods": []} + tt_id = tt_rows[0]["id"] + tt_neo4j_id = tt_rows[0].get("neo4j_node_id", "") - slots_result = s.run( - "MATCH (:TeacherTimetable {uuid_string: $id})-[:HAS_TIMETABLE_SLOT]->(sl:TimetableSlot) " - "RETURN sl", id=tt_id - ) - slots = [ - {"day_of_week": r["sl"]["day_of_week"], - "period_code": r["sl"]["period_code"], - "subject_class": r["sl"]["subject_class"], - "start_time": r["sl"]["start_time"], - "end_time": r["sl"]["end_time"]} - for r in slots_result - ] + slot_rows = ( + sb.supabase.table("teacher_timetable_slots") + .select("day_of_week,period_code,subject_class,start_time,end_time,week_cycle") + .eq("teacher_timetable_id", tt_id) + .execute() + .data or [] + ) - periods_rec = s.run( - "MATCH (st:SchoolTimetable) WHERE st.periods_template IS NOT NULL " - "RETURN st.periods_template AS p LIMIT 1" - ).single() - periods = json.loads(periods_rec["p"]) if periods_rec else [] + st_rows = ( + sb.supabase.table("school_timetables") + .select("periods_template") + .eq("institute_id", institute_id) + .limit(1) + .execute() + .data or [] + ) + periods = (st_rows[0].get("periods_template") or []) if st_rows else [] - return {"status": "ok", "timetable_id": tt_id, "slots": slots, - "periods": periods, "institute_db": institute_db} + return { + "status": "ok", + "timetable_id": tt_neo4j_id or str(tt_id), + "slots": slot_rows, + "periods": periods, + "institute_db": institute_db, + } except Exception as e: logger.error(f"Get timetable slots failed: {e}") return {"status": "error", "slots": [], "periods": []} @@ -285,34 +1105,100 @@ async def get_timetable_slots(credentials: dict = Depends(SupabaseBearer())) -> @router.post("/slots") async def save_timetable_slots( body: SlotsRequest, - credentials: dict = Depends(SupabaseBearer()) + credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: + user_id = credentials.get("sub", "") user_email = credentials.get("email", "") - institute_db, _ = _find_teacher_institute(user_email) + institute_id, institute_db, _ = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "error", "message": "Teacher not linked to school"} + + # ── 1. Supabase write (primary — fail hard if this fails) ──────────────── + sb = _sb() + tt_sb = ( + sb.supabase.table("teacher_timetables") + .select("id,neo4j_node_id") + .eq("profile_id", user_id) + .limit(1) + .execute() + ) + tt_sb_row = (tt_sb.data or [{}])[0] + tt_sb_id = tt_sb_row.get("id") + if not tt_sb_id: + return {"status": "error", "message": "No teacher timetable found — run /timetable/init first"} + + # Resolve class names → class UUIDs (best-effort; None if not found) + subject_names = list({s.subject_class.strip() for s in body.slots if s.subject_class.strip()}) + class_name_map: Dict[str, str] = {} + if subject_names and institute_id: + try: + classes = ( + sb.supabase.table("classes") + .select("id,name,class_code") + .eq("institute_id", institute_id) + .eq("is_active", True) + .execute() + .data or [] + ) + for c in classes: + if c.get("name"): + class_name_map[c["name"]] = c["id"] + if c.get("class_code"): + class_name_map[c["class_code"]] = c["id"] + except Exception as e: + logger.warning(f"Class name resolution failed (non-fatal): {e}") + + # Full replace: delete existing then insert + sb.supabase.table("teacher_timetable_slots").delete().eq( + "teacher_timetable_id", tt_sb_id + ).execute() + slot_rows = [ + { + "teacher_timetable_id": tt_sb_id, + "profile_id": user_id, + "institute_id": institute_id, + "day_of_week": slot.day_of_week, + "period_code": slot.period_code, + "subject_class": slot.subject_class.strip(), + "start_time": slot.start_time, + "end_time": slot.end_time, + "week_cycle": slot.week_cycle, + "class_id": class_name_map.get(slot.subject_class.strip()), + "neo4j_node_id": f"slot_{body.timetable_id}_{slot.day_of_week}_{slot.period_code}_{slot.week_cycle}", + } + for slot in body.slots + if slot.subject_class.strip() + ] + if slot_rows: + sb.supabase.table("teacher_timetable_slots").insert(slot_rows).execute() + logger.info(f"Saved {len(slot_rows)} slots to Supabase for timetable {tt_sb_id}") + + # ── 2. Neo4j write ──────────────────────────────────────────────────────── try: with driver_tools.get_session(database=institute_db) as s: s.run( - "MATCH (:TeacherTimetable {uuid_string: $id})-[:HAS_TIMETABLE_SLOT]->(sl) " - "DETACH DELETE sl", id=body.timetable_id + "MATCH (:TeacherTimetable {uuid_string: $id})-[:HAS_TIMETABLE_SLOT]->(sl) DETACH DELETE sl", + id=body.timetable_id, ) created = 0 for slot in body.slots: if not slot.subject_class.strip(): continue - slot_id = f"slot_{body.timetable_id}_{slot.day_of_week}_{slot.period_code}" + slot_id = f"slot_{body.timetable_id}_{slot.day_of_week}_{slot.period_code}_{slot.week_cycle}" s.run(""" MERGE (sl:TimetableSlot {uuid_string: $id}) SET sl.day_of_week = $day, sl.period_code = $code, sl.subject_class = $cls, sl.start_time = $start, - sl.end_time = $end, sl.node_storage_path = $path - WITH sl - MATCH (tt:TeacherTimetable {uuid_string: $tt_id}) + sl.end_time = $end, sl.week_cycle = $wc, + sl.node_storage_path = $path + WITH sl MATCH (tt:TeacherTimetable {uuid_string: $tt_id}) MERGE (tt)-[:HAS_TIMETABLE_SLOT]->(sl) - """, id=slot_id, day=slot.day_of_week, code=slot.period_code, + """, + id=slot_id, day=slot.day_of_week, code=slot.period_code, cls=slot.subject_class, start=slot.start_time, end=slot.end_time, - path=f"timetable/slots/{slot_id}", tt_id=body.timetable_id) + wc=slot.week_cycle, path=f"timetable/slots/{slot_id}", + tt_id=body.timetable_id, + ) created += 1 return {"status": "ok", "created": created} except Exception as e: @@ -322,9 +1208,10 @@ async def save_timetable_slots( @router.post("/init") async def init_teacher_timetable(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: - """Create a TeacherTimetable for the current teacher using the existing school calendar.""" + """Create a TeacherTimetable for the current teacher from the existing school calendar.""" + user_id = credentials.get("sub", "") user_email = credentials.get("email", "") - institute_db, teacher_uuid = _find_teacher_institute(user_email) + institute_id, institute_db, teacher_uuid = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "error", "message": "Teacher not linked to a school"} @@ -333,8 +1220,7 @@ async def init_teacher_timetable(credentials: dict = Depends(SupabaseBearer())) st_rec = s.run( "MATCH (st:SchoolTimetable) " "RETURN st.uuid_string AS id, " - " toString(st.start_date) AS start, " - " toString(st.end_date) AS end " + " toString(st.start_date) AS start, toString(st.end_date) AS end " "LIMIT 1" ).single() if not st_rec: @@ -348,20 +1234,73 @@ async def init_teacher_timetable(credentials: dict = Depends(SupabaseBearer())) s.run(""" MERGE (tt:TeacherTimetable {uuid_string: $id}) - SET tt.teacher_timetable_id = $id, - tt.start_date = date($start), tt.end_date = date($end), - tt.node_storage_path = $path - WITH tt - MATCH (teacher:Teacher {uuid_string: $t_uuid}) + SET tt.teacher_timetable_id = $id, tt.start_date = date($start), + tt.end_date = date($end), tt.node_storage_path = $path + WITH tt MATCH (teacher:Teacher {uuid_string: $t_uuid}) MERGE (teacher)-[:HAS_TIMETABLE]->(tt) - WITH tt - MATCH (st:SchoolTimetable {uuid_string: $st_id}) + WITH tt MATCH (st:SchoolTimetable {uuid_string: $st_id}) MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st) - """, id=teacher_tt_id, start=start_str, end=end_str, + """, + id=teacher_tt_id, start=start_str, end=end_str, path=f"timetable/{teacher_tt_id}", - t_uuid=teacher_uuid, st_id=school_tt_id) + t_uuid=teacher_uuid, st_id=school_tt_id, + ) + + # Supabase teacher_timetable record (best-effort) + if institute_id: + try: + sb = _sb() + stt_sb = ( + sb.supabase.table("school_timetables") + .select("id") + .eq("institute_id", institute_id) + .limit(1) + .execute() + ) + stt_sb_id = (stt_sb.data or [{}])[0].get("id") + if stt_sb_id: + sb.supabase.table("teacher_timetables").upsert( + { + "profile_id": user_id, + "institute_id": institute_id, + "school_timetable_id": stt_sb_id, + "start_date": start_str, + "end_date": end_str, + "neo4j_node_id": teacher_tt_id, + }, + on_conflict="profile_id,school_timetable_id", + ).execute() + except Exception as e: + logger.warning(f"Supabase teacher_timetable upsert (non-fatal): {e}") return {"status": "ok", "timetable_id": teacher_tt_id} except Exception as e: logger.error(f"Teacher timetable init failed: {e}") return {"status": "error", "message": str(e)} + + +@router.post("/sync-lessons") +async def sync_taught_lessons(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: + """ + Sync TaughtLesson Neo4j nodes from Supabase for the caller's school. + Also rebuilds TeacherTimetable and TimetableSlot nodes. + Safe to re-run (all MERGEs). + """ + user_id = credentials.get("sub", "") + user_email = credentials.get("email", "") + institute_id, institute_db, _ = _resolve_institute(user_id, user_email) + if not institute_db: + return {"status": "error", "message": "No school found"} + try: + sb = _sb() + tt_counts = _sync_teacher_timetables_to_neo4j(institute_id, institute_db, sb) + lesson_count = _sync_taught_lessons_to_neo4j(institute_id, institute_db, sb) + return { + "status": "ok", + "teacher_timetables": tt_counts["teacher_timetables"], + "slots": tt_counts["slots"], + "taught_lessons": lesson_count, + } + except Exception as e: + logger.error(f"sync_taught_lessons failed: {e}") + return {"status": "error", "message": str(e)} diff --git a/run/initialization/reset_environment.py b/run/initialization/reset_environment.py new file mode 100644 index 0000000..2d92389 --- /dev/null +++ b/run/initialization/reset_environment.py @@ -0,0 +1,172 @@ +""" +reset_environment.py — DESTRUCTIVE wipe of all non-permanent data. + +Clears: + - 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): + - 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()" +""" +import os +import time +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) + +KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" +KCAR_EMAIL = "kcar@kevlarai.com" + +# 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"} + + +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")] + targets = [db for db in all_dbs if db.startswith(pattern)] + for db in targets: + logger.info(f" DROP DATABASE `{db}`") + with dt.get_session(database="system") as s: + s.run(f"DROP DATABASE `{db}` IF EXISTS") + dropped.append(db) + return dropped + + +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]: + r = requests.get(f"{url}/auth/v1/admin/users", headers=headers, params={"per_page": 200}) + r.raise_for_status() + return r.json().get("users", []) + + +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[:100]}") + + +def reset() -> Dict[str, Any]: + logger.info("=" * 60) + logger.info("RESET ENVIRONMENT — destructive wipe starting") + logger.info("=" * 60) + results: Dict[str, Any] = {} + + # ── 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}") + + 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() + 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") + + # ── 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: + if u["email"] == KCAR_EMAIL: + continue + _supabase_delete_auth_user(url, headers, u["id"]) + deleted_emails.append(u["email"]) + time.sleep(0.1) + logger.info(f" Deleted {len(deleted_emails)} auth users") + + # 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"neq.{KCAR_ID}"}, + ) + requests.delete( + f"{url}/rest/v1/institute_memberships", + headers=headers, + params={"profile_id": f"neq.{KCAR_ID}"}, + ) + + results["supabase"] = {"deleted_users": deleted_emails} + + logger.info("\n" + "=" * 60) + logger.info("RESET COMPLETE") + logger.info("=" * 60) + return results + + +if __name__ == "__main__": + import json + print(json.dumps(reset(), indent=2, default=str)) diff --git a/run/initialization/seed_environment.py b/run/initialization/seed_environment.py new file mode 100644 index 0000000..d37e196 --- /dev/null +++ b/run/initialization/seed_environment.py @@ -0,0 +1,439 @@ +""" +seed_environment.py — idempotent full-environment rebuild. + +Assumes reset_environment.py has already been run (or it's the first boot). +Safe to re-run: all writes use UPSERT / MERGE. + +Schools +------- + KevlarAI 6585bf91-6ae8-4d72-ab54-cddf3ba4e648 kevlarai.test + Greenfield Academy a1b2c3d4-e5f6-7890-abcd-ef1234567890 greenfieldacademy.test + +Uniform accounts per school (10 × 2 = 20 total) +------------------------------------------------ + admin@{domain} school_admin + head@{domain} school_admin + physics@{domain} teacher + maths@{domain} teacher + teacher1@{domain} teacher + teacher2@{domain} teacher + teacher3@{domain} teacher + student1@{domain} student + student2@{domain} student + student3@{domain} student + +Run from inside the ccapi container: + python3 -c "from run.initialization.seed_environment import seed; seed()" +""" +import os +import time +import requests +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional + +from modules.logger_tool import initialise_logger + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) + +# ─── School constants ───────────────────────────────────────────────────────── + +KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648" +KEVLARAI_NAME = "KevlarAI" +KEVLARAI_URN = "KEVLARAI-001" +KEVLARAI_DOMAIN = "kevlarai.test" + +GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +GREENFIELD_NAME = "Greenfield Academy" +GREENFIELD_URN = "TEST-GFA-001" +GREENFIELD_DOMAIN = "greenfieldacademy.test" + +# ─── Passwords ──────────────────────────────────────────────────────────────── + +PWD_ADMIN = "Admin@Cc2025!" +PWD_TEACHER = "Teacher@Cc2025!" +PWD_STUDENT = "Student@Cc2025!" + +# ─── Account template ──────────────────────────────────────────────────────── + +def _school_accounts(domain: str, institute_id: str) -> List[Dict]: + return [ + # school_admin accounts + { + "prefix": "admin", "email": f"admin@{domain}", + "full_name": "Alex Admin", "display_name": "Alex", + "username": f"admin.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "school_admin", "password": PWD_ADMIN, + "institute_id": institute_id, + }, + { + "prefix": "head", "email": f"head@{domain}", + "full_name": "Helen Head", "display_name": "Helen", + "username": f"head.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "school_admin", "password": PWD_ADMIN, + "institute_id": institute_id, + }, + # teacher accounts + { + "prefix": "physics", "email": f"physics@{domain}", + "full_name": "Phil Physics", "display_name": "Phil", + "username": f"physics.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "teacher", "password": PWD_TEACHER, + "institute_id": institute_id, + }, + { + "prefix": "maths", "email": f"maths@{domain}", + "full_name": "Mary Maths", "display_name": "Mary", + "username": f"maths.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "teacher", "password": PWD_TEACHER, + "institute_id": institute_id, + }, + { + "prefix": "teacher1", "email": f"teacher1@{domain}", + "full_name": "Tom Teacher", "display_name": "Tom", + "username": f"teacher1.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "teacher", "password": PWD_TEACHER, + "institute_id": institute_id, + }, + { + "prefix": "teacher2", "email": f"teacher2@{domain}", + "full_name": "Tara Teach", "display_name": "Tara", + "username": f"teacher2.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "teacher", "password": PWD_TEACHER, + "institute_id": institute_id, + }, + { + "prefix": "teacher3", "email": f"teacher3@{domain}", + "full_name": "Tim Teachwell", "display_name": "Tim", + "username": f"teacher3.{domain.replace('.', '_')}", + "user_type": "teacher", "role": "teacher", "password": PWD_TEACHER, + "institute_id": institute_id, + }, + # student accounts + { + "prefix": "student1", "email": f"student1@{domain}", + "full_name": "Sam Student", "display_name": "Sam", + "username": f"student1.{domain.replace('.', '_')}", + "user_type": "student", "role": "student", "password": PWD_STUDENT, + "institute_id": institute_id, + }, + { + "prefix": "student2", "email": f"student2@{domain}", + "full_name": "Sophie Study", "display_name": "Sophie", + "username": f"student2.{domain.replace('.', '_')}", + "user_type": "student", "role": "student", "password": PWD_STUDENT, + "institute_id": institute_id, + }, + { + "prefix": "student3", "email": f"student3@{domain}", + "full_name": "Steve Scholar", "display_name": "Steve", + "username": f"student3.{domain.replace('.', '_')}", + "user_type": "student", "role": "student", "password": PWD_STUDENT, + "institute_id": institute_id, + }, + ] + +ALL_ACCOUNTS = ( + _school_accounts(KEVLARAI_DOMAIN, KEVLARAI_ID) + + _school_accounts(GREENFIELD_DOMAIN, GREENFIELD_ID) +) + +# ─── Supabase helpers ───────────────────────────────────────────────────────── + +def _sb_ctx(): + url = os.environ["SUPABASE_URL"] + key = os.environ["SERVICE_ROLE_KEY"] + headers = { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + return url, headers + + +def _auth_post(url, headers, path, data): + return requests.post(f"{url}/auth/v1/admin{path}", headers=headers, json=data) + + +def _auth_get(url, headers, path, params=None): + r = requests.get(f"{url}/auth/v1/admin{path}", headers=headers, params=params) + r.raise_for_status() + return r.json() + + +def _rest_upsert(url, headers, table, data, on_conflict): + h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"} + r = requests.post( + f"{url}/rest/v1/{table}", + headers=h, + json=data, + params={"on_conflict": on_conflict}, + ) + return r + + +def _rest_patch(url, headers, table, match_col, match_val, data): + r = requests.patch( + f"{url}/rest/v1/{table}", + headers={**headers, "Prefer": "return=minimal"}, + params={match_col: f"eq.{match_val}"}, + json=data, + ) + return r + + +# ─── Main seed function ─────────────────────────────────────────────────────── + +def seed() -> Dict[str, Any]: + from modules.database.services.provisioning_service import ProvisioningService + from modules.database.services.neo4j_service import Neo4jService + from modules.database.init.init_calendar import create_calendar + + url, headers = _sb_ctx() + errors: List[str] = [] + results: Dict[str, Any] = {} + + # ── Step 1: Fix KevlarAI institute record ───────────────────────────────── + logger.info("=" * 60) + logger.info("SEED ENVIRONMENT") + logger.info("=" * 60) + logger.info("\n[1] KevlarAI institute record...") + try: + r = _rest_upsert(url, headers, "institutes", { + "id": KEVLARAI_ID, + "name": KEVLARAI_NAME, + "urn": KEVLARAI_URN, + "status": "active", + "website": "https://kevlarai.com", + "address": {"line1": "1 AI Lane", "city": "London", "postcode": "EC1A 1BB"}, + "metadata": {"headteacher": "Alex Admin", "seeded": True}, + }, on_conflict="id") + if r.status_code in (200, 201): + logger.info(" KevlarAI upserted ✓") + else: + raise Exception(r.text[:200]) + except Exception as e: + errors.append(f"kevlarai_institute: {e}") + logger.error(f" {e}") + + # ── Step 2: Create Greenfield Academy if needed ─────────────────────────── + logger.info("[2] Greenfield Academy institute record...") + try: + neo4j_uuid_greenfield = GREENFIELD_ID.replace("-", "") + r = _rest_upsert(url, headers, "institutes", { + "id": GREENFIELD_ID, + "name": GREENFIELD_NAME, + "urn": GREENFIELD_URN, + "status": "active", + "website": "https://greenfieldacademy.test", + "address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"}, + "metadata": {"headteacher": "Alex Admin", "seeded": True}, + "neo4j_uuid_string": neo4j_uuid_greenfield, + }, on_conflict="id") + if r.status_code in (200, 201): + logger.info(" Greenfield Academy upserted ✓") + else: + raise Exception(r.text[:200]) + except Exception as e: + errors.append(f"greenfield_institute: {e}") + logger.error(f" {e}") + + # ── Step 3: Provision Neo4j for both schools ────────────────────────────── + logger.info("[3] Neo4j school provisioning...") + provisioner = ProvisioningService() + school_dbs: Dict[str, str] = {} + + for iid, name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]: + try: + result = provisioner.ensure_school(iid) + db = result["db_name"] + school_dbs[iid] = db + logger.info(f" {name}: {db} ✓") + except Exception as e: + errors.append(f"ensure_school {name}: {e}") + logger.error(f" {name}: {e}") + # derive fallback db name + school_dbs[iid] = f"cc.institutes.{iid.replace('-', '')}" + + # ── Step 4: Rebuild classroomcopilot global calendar ───────────────────── + logger.info("[4] classroomcopilot global calendar (2024–2028)...") + try: + neo4j_svc = Neo4jService() + neo4j_svc.create_database("classroomcopilot") + logger.info(" DB created, waiting 5s for availability...") + time.sleep(5) + start_dt = datetime(2024, 1, 1) + end_dt = datetime(2028, 12, 31) + create_calendar("classroomcopilot", start_dt, end_dt) + logger.info(" Calendar built ✓") + results["global_calendar"] = "ok" + except Exception as e: + errors.append(f"global_calendar: {e}") + logger.error(f" {e}") + results["global_calendar"] = "error" + + # ── Step 5: Create / verify auth users ──────────────────────────────────── + logger.info("[5] Creating auth users (20 accounts)...") + try: + existing = _auth_get(url, headers, "/users", {"per_page": 200}).get("users", []) + existing_by_email = {u["email"]: u for u in existing} + except Exception as e: + errors.append(f"list_auth_users: {e}") + existing_by_email = {} + + created_users: Dict[str, str] = {} # email → uid + for spec in ALL_ACCOUNTS: + email = spec["email"] + if email in existing_by_email: + created_users[email] = existing_by_email[email]["id"] + logger.info(f" {email}: exists [{created_users[email][:8]}]") + continue + r = _auth_post(url, headers, "/users", { + "email": email, + "password": spec["password"], + "email_confirm": True, + "user_metadata": { + "username": spec["username"], + "full_name": spec["full_name"], + "display_name": spec["display_name"], + "user_type": spec["user_type"], + }, + }) + if r.status_code in (200, 201): + uid = r.json()["id"] + created_users[email] = uid + logger.info(f" {email}: created [{uid[:8]}]") + else: + errors.append(f"create {email}: {r.text[:150]}") + logger.error(f" {email}: {r.text[:150]}") + time.sleep(0.2) + + results["users_created"] = len(created_users) + + # ── Step 6: Upsert profiles and memberships ─────────────────────────────── + logger.info("[6] Upserting profiles and memberships...") + for spec in ALL_ACCOUNTS: + uid = created_users.get(spec["email"]) + if not uid: + continue + try: + _rest_upsert(url, headers, "profiles", { + "id": uid, + "email": spec["email"], + "user_type": spec["user_type"], + "username": spec["username"], + "full_name": spec["full_name"], + "display_name": spec["display_name"], + "school_id": spec["institute_id"], + "neo4j_sync_status": "pending", + }, on_conflict="id") + _rest_upsert(url, headers, "institute_memberships", { + "profile_id": uid, + "institute_id": spec["institute_id"], + "role": spec["role"], + "metadata": {}, + }, on_conflict="profile_id,institute_id") + except Exception as e: + errors.append(f"profile/membership {spec['email']}: {e}") + logger.error(f" {spec['email']}: {e}") + + logger.info(" Profiles and memberships upserted ✓") + + # ── Step 7: Merge Neo4j Teacher/Student nodes ───────────────────────────── + logger.info("[7] Merging Neo4j worker nodes...") + try: + from neo4j import GraphDatabase + driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%")) + + # Group by institute DB + by_db: Dict[str, List[Dict]] = {} + for spec in ALL_ACCOUNTS: + uid = created_users.get(spec["email"]) + if not uid: + continue + db = school_dbs.get(spec["institute_id"]) + if not db: + continue + by_db.setdefault(db, []).append({**spec, "uid": uid}) + + for db, users in by_db.items(): + with driver.session(database=db) as s: + for u in users: + label = "Teacher" if u["user_type"] == "teacher" else "Student" + s.run( + f"MERGE (n:{label} {{uuid_string: $uid}}) " + "SET n.worker_email = $email, " + " n.worker_name = $name, " + " n.worker_type = $utype", + uid=u["uid"], email=u["email"], + name=u["full_name"], utype=u["user_type"], + ) + logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓") + + driver.close() + results["neo4j_nodes"] = "ok" + except Exception as e: + errors.append(f"neo4j_nodes: {e}") + logger.error(f" {e}") + results["neo4j_nodes"] = "error" + + # ── Ensure kcar is a platform super-admin ───────────────────────────────── + logger.info("[8] Ensuring kcar platform admin record...") + KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" + try: + _rest_upsert(url, headers, "admin_profiles", { + "id": KCAR_ID, + "email": "kcar@kevlarai.com", + "role": "super_admin", + "permissions": ["all"], + "metadata": {"seeded": True}, + }, on_conflict="id") + logger.info(" kcar → admin_profiles ✓") + except Exception as e: + errors.append(f"kcar_admin: {e}") + logger.error(f" {e}") + + # ── Summary ─────────────────────────────────────────────────────────────── + results["success"] = len(errors) == 0 + results["errors"] = errors + + _print_credential_sheet(created_users) + + logger.info("\n" + "=" * 60) + if errors: + logger.info(f"SEED COMPLETE with {len(errors)} error(s)") + for e in errors: + logger.info(f" ✗ {e}") + else: + logger.info("SEED COMPLETE — all steps succeeded") + logger.info("=" * 60) + return results + + +def _print_credential_sheet(created_users: Dict[str, str]): + PAD = 36 + logger.info("\n" + "=" * 70) + logger.info("CREDENTIAL SHEET") + logger.info("=" * 70) + logger.info(f" {'ROLE':<16} {'EMAIL':<{PAD}} PASSWORD") + logger.info(f" {'-'*14} {'-'*(PAD-2)} -----------") + logger.info(f" {'[platform admin]':<16} {'kcar@kevlarai.com':<{PAD}} KevlarAI2025!") + logger.info("") + + for school_id, domain, label in [ + (KEVLARAI_ID, KEVLARAI_DOMAIN, "KevlarAI"), + (GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"), + ]: + logger.info(f" [{label}]") + for spec in ALL_ACCOUNTS: + if spec["institute_id"] != school_id: + continue + uid = created_users.get(spec["email"], "—") + status = f"[{uid[:8]}]" if uid != "—" else "[MISSING]" + logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {spec['password']} {status}") + logger.info("") + logger.info("=" * 70) + + +if __name__ == "__main__": + import json + print(json.dumps(seed(), indent=2, default=str)) diff --git a/run/routers.py b/run/routers.py index af3062f..703c7ba 100644 --- a/run/routers.py +++ b/run/routers.py @@ -13,6 +13,10 @@ from routers.database.tools.graph_tree_router import router as graph_tree_router from routers.database.tools.user_init_router import router as user_init_router from routers.database.tools.timetable_builder_router import router as timetable_builder_router from routers.database.tools.school_router import router as school_router +from routers.database.tools.classes_router import router as classes_router +from routers.database.tools.taught_lessons_router import router as taught_lessons_router +from routers.database.tools.invitations_router import router as invitations_router +from routers.database.tools.platform_admin_router import router as platform_admin_router from routers.database.files import cabinets as cabinets_router from routers.database.files import files as files_router from routers.simple_upload import router as simple_upload_router @@ -63,6 +67,10 @@ def register_routes(app: FastAPI): app.include_router(user_init_router, prefix="/user", tags=["User"]) app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"]) app.include_router(school_router, prefix="/school", tags=["School"]) + app.include_router(classes_router, prefix="/database/timetable/classes", tags=["Classes"]) + app.include_router(taught_lessons_router, prefix="/timetable", tags=["Taught Lessons"]) + app.include_router(invitations_router, prefix="/users", tags=["People"]) + app.include_router(platform_admin_router, prefix="/admin", tags=["Platform Admin"]) app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"]) # Database Filesystem Routes