feat(phase-b): Supabase-first timetable, classes, enrollment, and student views

- timetable_builder_router: Supabase-primary slot write (POST /timetable/slots),
  week_cycle support, GET /slots reads from Supabase, materialize-periods endpoint,
  rebuild-neo4j endpoint, sync-lessons endpoint (Track B: TaughtLesson Neo4j nodes),
  _sync_teacher_timetables_to_neo4j and _sync_taught_lessons_to_neo4j helpers
- classes_router: GET /{class_id} enriched with profiles + enrollment_requests,
  GET /school/students for admin search, PATCH /enrollment-requests/{id} approve/reject
- taught_lessons_router: GET /student/lessons student week view with enrichment
- school_router: academic_periods sync, day-type management
- platform_admin_router + platform_admin: POST /admin/reset and /admin/seed endpoints
- invitations_router: teacher invite scaffolding
- reset_environment + seed_environment: idempotent dev environment scripts
- graph_tree_router: Supabase-first institute resolution
- provisioning_service: neo4j_private_db_name column support
- main.py + run/routers.py: register new routers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-27 02:55:44 +01:00
parent 7c75481245
commit abf8d05ca1
13 changed files with 3906 additions and 203 deletions

10
main.py
View File

@ -368,6 +368,7 @@ Startup modes:
infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets) infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)
demo-school - Create demo school (KevlarAI) demo-school - Create demo school (KevlarAI)
demo-users - Create demo users demo-users - Create demo users
seed-test - Seed full test environment (2 schools, all test users)
gais-data - Import GAIS data (Edubase, etc.) gais-data - Import GAIS data (Edubase, etc.)
dev - Run development server with auto-reload dev - Run development server with auto-reload
prod - Run production server (for Docker/containerized deployment) prod - Run production server (for Docker/containerized deployment)
@ -376,7 +377,7 @@ Startup modes:
parser.add_argument( parser.add_argument(
'--mode', '-m', '--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', default='dev',
help='Startup mode (default: dev)' help='Startup mode (default: dev)'
) )
@ -409,6 +410,13 @@ if __name__ == "__main__":
success = run_demo_users_mode() success = run_demo_users_mode()
sys.exit(0 if success else 1) 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': elif args.mode == 'gais-data':
# Run GAIS data import # Run GAIS data import
success = run_gais_data_mode() success = run_gais_data_mode()

View File

@ -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

View File

@ -229,6 +229,7 @@ class ProvisioningService:
"neo4j_private_db_name": school_db, "neo4j_private_db_name": school_db,
"neo4j_private_sync_status": "ready", "neo4j_private_sync_status": "ready",
"neo4j_private_sync_at": datetime.utcnow().isoformat(), "neo4j_private_sync_at": datetime.utcnow().isoformat(),
"neo4j_uuid_string": self._sanitize_component(institute_id),
} }
try: try:
( (

View File

@ -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}

View File

@ -1,10 +1,10 @@
import os import os
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from modules.logger_tool import initialise_logger from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer from modules.auth.supabase_bearer import SupabaseBearer
import modules.database.tools.neo4j_driver_tools as driver_tools 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) logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter() router = APIRouter()
@ -16,6 +16,50 @@ def _user_to_teacher_db(user_id: str) -> str:
return f"cc.users.teacher.{user_id.replace('-', '')}" 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]]: 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.""" """Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs."""
if not user_email: if not user_email:
@ -164,21 +208,31 @@ def _section(section_id: str, label: str, db: str, status: str,
def _build_calendar_section() -> Dict: def _build_calendar_section() -> Dict:
current_year = str(datetime.now().year) try:
months = _query_calendar_months(current_year) with driver_tools.get_session(database="classroomcopilot") as session:
calendar_year_node = { rows = session.run(
"neo4j_node_id": current_year, "MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
"label": current_year, ).data()
"node_type": "CalendarYear", if not rows:
"neo4j_db_name": "classroomcopilot", return _section("calendar", "Calendar", "classroomcopilot", "empty")
"is_section": False, year_nodes = [
"has_children": True, {
"children": months, "neo4j_node_id": r["y"]["uuid_string"],
} "label": r["y"].get("year") or r["y"]["uuid_string"],
return _section( "node_type": "CalendarYear",
"calendar", "Calendar", "classroomcopilot", "populated", "neo4j_db_name": "classroomcopilot",
has_children=True, children=[calendar_year_node], "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: 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, "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 = [ sections = [
_build_calendar_section(), _build_calendar_section(),
@ -546,8 +600,9 @@ async def get_node_children(
@router.get("/calendar/academic") @router.get("/calendar/academic")
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
user_id = credentials.get("sub", "")
user_email = credentials.get("email", "") user_email = credentials.get("email", "")
institute_db, _ = _find_teacher_institute(user_email) _, institute_db, _ = _resolve_institute(user_id, user_email)
if not institute_db: if not institute_db:
return {"status": "no_school", "terms": []} return {"status": "no_school", "terms": []}
try: try:

View File

@ -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)}

View File

@ -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}

View File

@ -318,3 +318,286 @@ def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: s
"institute_id": school_id, "institute_id": school_id,
"role": role, "role": role,
}).execute() }).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,
}

View File

@ -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),
}

File diff suppressed because it is too large Load Diff

View File

@ -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))

View File

@ -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 (20242028)...")
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))

View File

@ -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.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.timetable_builder_router import router as timetable_builder_router
from routers.database.tools.school_router import router as school_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 cabinets as cabinets_router
from routers.database.files import files as files_router from routers.database.files import files as files_router
from routers.simple_upload import router as simple_upload_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(user_init_router, prefix="/user", tags=["User"])
app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"]) app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"])
app.include_router(school_router, prefix="/school", tags=["School"]) 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"]) app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"])
# Database Filesystem Routes # Database Filesystem Routes