- 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>
621 lines
19 KiB
Python
621 lines
19 KiB
Python
"""
|
|
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}
|