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