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