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 <[email protected]>
This commit is contained in:
@@ -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}
|
||||
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
import modules.database.tools.neo4j_driver_tools as driver_tools
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
@@ -16,6 +16,50 @@ def _user_to_teacher_db(user_id: str) -> str:
|
||||
return f"cc.users.teacher.{user_id.replace('-', '')}"
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _find_teacher_uuid(db: str, user_email: str) -> Optional[str]:
|
||||
"""Query teacher UUID from a known Neo4j institute DB."""
|
||||
try:
|
||||
with driver_tools.get_session(database=db) as session:
|
||||
rec = session.run(
|
||||
'MATCH (t:Teacher) WHERE t.worker_email = $email '
|
||||
'RETURN t.uuid_string AS uuid LIMIT 1',
|
||||
email=user_email,
|
||||
).single()
|
||||
if rec:
|
||||
return rec['uuid']
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_institute(
|
||||
user_id: str, user_email: str
|
||||
) -> tuple:
|
||||
"""Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid).
|
||||
Supabase-first lookup with Neo4j email-scan fallback."""
|
||||
try:
|
||||
sb = _sb()
|
||||
p = sb.supabase.table('profiles').select('school_id').eq('id', user_id).single().execute()
|
||||
school_id = (p.data or {}).get('school_id')
|
||||
if school_id:
|
||||
i = sb.supabase.table('institutes').select('id,neo4j_uuid_string').eq('id', str(school_id)).single().execute()
|
||||
inst = i.data or {}
|
||||
neo4j_uuid = inst.get('neo4j_uuid_string')
|
||||
if neo4j_uuid:
|
||||
db = f'cc.institutes.{neo4j_uuid}'
|
||||
teacher_uuid = _find_teacher_uuid(db, user_email)
|
||||
return str(school_id), db, teacher_uuid
|
||||
except Exception as e:
|
||||
logger.warning(f'Supabase-first institute resolve failed: {e}')
|
||||
# Fallback: scan Neo4j
|
||||
db, teacher_uuid = _find_teacher_institute(user_email)
|
||||
return None, db, teacher_uuid
|
||||
|
||||
|
||||
def _find_teacher_institute(user_email: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs."""
|
||||
if not user_email:
|
||||
@@ -164,21 +208,31 @@ def _section(section_id: str, label: str, db: str, status: str,
|
||||
|
||||
|
||||
def _build_calendar_section() -> Dict:
|
||||
current_year = str(datetime.now().year)
|
||||
months = _query_calendar_months(current_year)
|
||||
calendar_year_node = {
|
||||
"neo4j_node_id": current_year,
|
||||
"label": current_year,
|
||||
"node_type": "CalendarYear",
|
||||
"neo4j_db_name": "classroomcopilot",
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
"children": months,
|
||||
}
|
||||
return _section(
|
||||
"calendar", "Calendar", "classroomcopilot", "populated",
|
||||
has_children=True, children=[calendar_year_node],
|
||||
)
|
||||
try:
|
||||
with driver_tools.get_session(database="classroomcopilot") as session:
|
||||
rows = session.run(
|
||||
"MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
|
||||
).data()
|
||||
if not rows:
|
||||
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
||||
year_nodes = [
|
||||
{
|
||||
"neo4j_node_id": r["y"]["uuid_string"],
|
||||
"label": r["y"].get("year") or r["y"]["uuid_string"],
|
||||
"node_type": "CalendarYear",
|
||||
"neo4j_db_name": "classroomcopilot",
|
||||
"is_section": False,
|
||||
"has_children": True,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return _section(
|
||||
"calendar", "Calendar", "classroomcopilot", "populated",
|
||||
has_children=True, children=year_nodes,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Calendar section build failed: {e}")
|
||||
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
||||
|
||||
|
||||
def _build_timetable_section(institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict:
|
||||
@@ -508,7 +562,7 @@ async def get_teacher_graph_tree(
|
||||
"has_children": True,
|
||||
}
|
||||
|
||||
institute_db, teacher_node_uuid = _find_teacher_institute(user_email)
|
||||
_, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email)
|
||||
|
||||
sections = [
|
||||
_build_calendar_section(),
|
||||
@@ -546,8 +600,9 @@ async def get_node_children(
|
||||
|
||||
@router.get("/calendar/academic")
|
||||
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
user_email = credentials.get("email", "")
|
||||
institute_db, _ = _find_teacher_institute(user_email)
|
||||
_, institute_db, _ = _resolve_institute(user_id, user_email)
|
||||
if not institute_db:
|
||||
return {"status": "no_school", "terms": []}
|
||||
try:
|
||||
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -318,3 +318,286 @@ def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: s
|
||||
"institute_id": school_id,
|
||||
"role": role,
|
||||
}).execute()
|
||||
|
||||
|
||||
# ─── School Overview ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_school_overview(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Summary dashboard for school admins: staff/student/class counts,
|
||||
calendar snapshot (terms, total academic days, current/next term).
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
# Role check
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
user_role = (mem.data or {}).get("role", "teacher")
|
||||
|
||||
# Counts
|
||||
staff_roles = ["teacher", "school_admin", "department_head"]
|
||||
staff_rows = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.in_("role", staff_roles)
|
||||
.execute()
|
||||
)
|
||||
student_rows = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("role", "student")
|
||||
.execute()
|
||||
)
|
||||
class_rows = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("is_active", True)
|
||||
.execute()
|
||||
)
|
||||
|
||||
# Calendar snapshot from academic_terms
|
||||
terms = (
|
||||
sb.supabase.table("academic_terms")
|
||||
.select("id,term_name,term_number,start_date,end_date")
|
||||
.eq("institute_id", school_id)
|
||||
.order("term_number")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Academic day counts per term
|
||||
if terms:
|
||||
term_ids = [t["id"] for t in terms]
|
||||
day_counts_res = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("academic_term_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("day_type", "Academic")
|
||||
.in_("academic_term_id", term_ids)
|
||||
.execute()
|
||||
)
|
||||
# Supabase doesn't group-by server-side; count manually per term
|
||||
all_days = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("academic_term_id,day_type")
|
||||
.eq("institute_id", school_id)
|
||||
.in_("academic_term_id", term_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import defaultdict
|
||||
academic_day_count: Dict[str, int] = defaultdict(int)
|
||||
total_day_count: Dict[str, int] = defaultdict(int)
|
||||
for d in all_days:
|
||||
total_day_count[d["academic_term_id"]] += 1
|
||||
if d["day_type"] == "Academic":
|
||||
academic_day_count[d["academic_term_id"]] += 1
|
||||
|
||||
from datetime import date
|
||||
today_str = str(date.today())
|
||||
for t in terms:
|
||||
t["academic_days"] = academic_day_count.get(t["id"], 0)
|
||||
t["total_days"] = total_day_count.get(t["id"], 0)
|
||||
if t["start_date"] <= today_str <= t["end_date"]:
|
||||
t["is_current"] = True
|
||||
else:
|
||||
t["is_current"] = False
|
||||
|
||||
pending_invites = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_role": user_role,
|
||||
"counts": {
|
||||
"staff": staff_rows.count or 0,
|
||||
"students": student_rows.count or 0,
|
||||
"classes": class_rows.count or 0,
|
||||
"pending_invitations": pending_invites.count or 0,
|
||||
},
|
||||
"terms": terms,
|
||||
"has_calendar": len(terms) > 0,
|
||||
}
|
||||
|
||||
|
||||
# ─── Calendar days (admin view) ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/calendar/days")
|
||||
async def list_calendar_days(
|
||||
term_id: Optional[str] = None,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Return academic_days for the school, optionally filtered by term.
|
||||
Includes week_cycle from the parent academic_week.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
q = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("id,date,day_of_week,day_type,academic_week_id,academic_term_id,academic_day_number,excluded_period_codes")
|
||||
.eq("institute_id", school_id)
|
||||
.order("date")
|
||||
)
|
||||
if term_id:
|
||||
q = q.eq("academic_term_id", term_id)
|
||||
|
||||
days = q.execute().data or []
|
||||
|
||||
# Enrich with week_cycle
|
||||
if days:
|
||||
week_ids = list({d["academic_week_id"] for d in days if d.get("academic_week_id")})
|
||||
weeks = (
|
||||
sb.supabase.table("academic_weeks")
|
||||
.select("id,week_number,week_cycle")
|
||||
.in_("id", week_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
wk_map = {w["id"]: w for w in weeks}
|
||||
for d in days:
|
||||
wk = wk_map.get(d.get("academic_week_id", ""), {})
|
||||
d["week_cycle"] = wk.get("week_cycle", "")
|
||||
d["week_number"] = wk.get("week_number")
|
||||
|
||||
return {"status": "ok", "days": days, "total": len(days)}
|
||||
|
||||
|
||||
@router.patch("/calendar/days/{day_id}")
|
||||
async def update_calendar_day(
|
||||
day_id: str,
|
||||
body: Dict[str, Any],
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Override day_type for a single academic day (school admin only).
|
||||
Syncs academic_periods: removes periods for non-Academic days,
|
||||
creates periods from periods_template for newly-Academic days.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
# Verify admin role
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if (mem.data or {}).get("role") not in ("school_admin", "department_head"):
|
||||
raise HTTPException(status_code=403, detail="School admin access required")
|
||||
|
||||
# Verify day belongs to school
|
||||
day = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("*")
|
||||
.eq("id", day_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Day not found")
|
||||
|
||||
new_day_type = body.get("day_type", day["day_type"])
|
||||
excluded = body.get("excluded_period_codes", day.get("excluded_period_codes") or [])
|
||||
|
||||
valid_types = {"Academic", "Holiday", "Staff", "OffTimetable"}
|
||||
if new_day_type not in valid_types:
|
||||
raise HTTPException(status_code=400, detail=f"day_type must be one of {sorted(valid_types)}")
|
||||
|
||||
# Update the day
|
||||
sb.supabase.table("academic_days").update({
|
||||
"day_type": new_day_type,
|
||||
"excluded_period_codes": excluded,
|
||||
}).eq("id", day_id).execute()
|
||||
|
||||
old_type = day["day_type"]
|
||||
periods_changed = 0
|
||||
|
||||
if old_type == "Academic" and new_day_type != "Academic":
|
||||
# Remove periods for this day
|
||||
del_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.delete()
|
||||
.eq("academic_day_id", day_id)
|
||||
.execute()
|
||||
)
|
||||
periods_changed = -(len(del_res.data or []))
|
||||
|
||||
elif old_type != "Academic" and new_day_type == "Academic":
|
||||
# Create periods from template
|
||||
stt = (
|
||||
sb.supabase.table("school_timetables")
|
||||
.select("periods_template")
|
||||
.eq("institute_id", school_id)
|
||||
.order("created_at", desc=True)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
template = (stt[0].get("periods_template") or []) if stt else []
|
||||
skip = set(excluded)
|
||||
new_periods = []
|
||||
for period in template:
|
||||
if period.get("code") in skip:
|
||||
continue
|
||||
new_periods.append({
|
||||
"academic_day_id": day_id,
|
||||
"institute_id": school_id,
|
||||
"period_code": period["code"],
|
||||
"period_name": period.get("name", period["code"]),
|
||||
"start_time": period.get("start_time"),
|
||||
"end_time": period.get("end_time"),
|
||||
"period_type": period.get("period_type", "lesson"),
|
||||
})
|
||||
if new_periods:
|
||||
ins_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.upsert(new_periods, on_conflict="academic_day_id,period_code")
|
||||
.execute()
|
||||
)
|
||||
periods_changed = len(ins_res.data or [])
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"day_id": day_id,
|
||||
"new_day_type": new_day_type,
|
||||
"periods_changed": periods_changed,
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user