import os import uuid import aiohttp 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() _OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") _OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3") _LLM_TIMEOUT = 60 VALID_STATUS = {"draft", "ready", "archived"} VALID_FIELDS = {"objectives", "activity_description", "title"} BLOOM_LEVELS = {"remember", "understand", "apply", "analyse", "evaluate", "create"} # ─── Supabase helpers ───────────────────────────────────────────────────────── 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 _is_creator(plan: Dict[str, Any], user_id: str) -> bool: return str(plan.get("created_by", "")) == str(user_id) def _can_edit_plan(sb: SupabaseServiceRoleClient, plan_id: str, user_id: str) -> bool: try: plan_res = ( sb.supabase.table("planned_lessons") .select("created_by") .eq("id", plan_id) .single() .execute() ) if not plan_res.data: return False if str(plan_res.data.get("created_by", "")) == str(user_id): return True collab = ( sb.supabase.table("lesson_collaborators") .select("can_edit") .eq("planned_lesson_id", plan_id) .eq("profile_id", user_id) .single() .execute() ) return bool((collab.data or {}).get("can_edit", False)) except Exception: return False # ─── Request models ─────────────────────────────────────────────────────────── class CreatePlanRequest(BaseModel): title: str class_id: Optional[str] = None subject: Optional[str] = None year_group: Optional[str] = None estimated_duration_minutes: Optional[int] = None objectives: Optional[List[Dict[str, Any]]] = None activities: Optional[List[Dict[str, Any]]] = None status: Optional[str] = "draft" tags: Optional[List[str]] = None topic_code: Optional[str] = None whiteboard_room_id: Optional[str] = None course_id: Optional[str] = None sequence_number: Optional[int] = None class UpdatePlanRequest(BaseModel): title: Optional[str] = None class_id: Optional[str] = None subject: Optional[str] = None year_group: Optional[str] = None estimated_duration_minutes: Optional[int] = None objectives: Optional[List[Dict[str, Any]]] = None activities: Optional[List[Dict[str, Any]]] = None status: Optional[str] = None tags: Optional[List[str]] = None topic_code: Optional[str] = None whiteboard_room_id: Optional[str] = None course_id: Optional[str] = None sequence_number: Optional[int] = None class DeliverPlanRequest(BaseModel): taught_lesson_id: Optional[str] = None class_id: Optional[str] = None notes: Optional[str] = None class AddCollaboratorRequest(BaseModel): profile_id: str can_edit: bool = True class SuggestRequest(BaseModel): field: str context: Optional[str] = None activity_section: Optional[str] = None objective_texts: Optional[List[str]] = None # ─── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/plans") async def list_plans( class_id: Optional[str] = None, status: Optional[str] = None, subject: Optional[str] = None, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() q = ( sb.supabase.table("planned_lessons") .select("*") .eq("institute_id", institute_id) ) if class_id: q = q.eq("class_id", class_id) if status: q = q.eq("status", status) if subject: q = q.eq("subject", subject) owned = q.eq("created_by", user_id).execute().data or [] collab_rows = ( sb.supabase.table("lesson_collaborators") .select("planned_lesson_id") .eq("profile_id", user_id) .execute() .data or [] ) collab_plan_ids = [r["planned_lesson_id"] for r in collab_rows] collab_plans: List[Dict] = [] if collab_plan_ids: q2 = ( sb.supabase.table("planned_lessons") .select("*") .eq("institute_id", institute_id) .in_("id", collab_plan_ids) ) if class_id: q2 = q2.eq("class_id", class_id) if status: q2 = q2.eq("status", status) if subject: q2 = q2.eq("subject", subject) collab_plans = q2.execute().data or [] seen_ids: set = set() all_plans: List[Dict] = [] for p in owned + collab_plans: if p["id"] not in seen_ids: seen_ids.add(p["id"]) all_plans.append(p) if not all_plans: return {"plans": []} plan_ids = [p["id"] for p in all_plans] collab_counts: Dict[str, int] = {} delivery_counts: Dict[str, int] = {} try: cc = ( sb.supabase.table("lesson_collaborators") .select("planned_lesson_id") .in_("planned_lesson_id", plan_ids) .execute() .data or [] ) for r in cc: collab_counts[r["planned_lesson_id"]] = collab_counts.get(r["planned_lesson_id"], 0) + 1 except Exception: pass try: dc = ( sb.supabase.table("lesson_deliveries") .select("planned_lesson_id") .in_("planned_lesson_id", plan_ids) .execute() .data or [] ) for r in dc: delivery_counts[r["planned_lesson_id"]] = delivery_counts.get(r["planned_lesson_id"], 0) + 1 except Exception: pass enriched = [ { **p, "collaborator_count": collab_counts.get(p["id"], 0), "delivery_count": delivery_counts.get(p["id"], 0), "is_owner": str(p.get("created_by", "")) == str(user_id), } for p in all_plans ] return {"plans": enriched} @router.post("/plans") async def create_plan( body: CreatePlanRequest, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() if body.status and body.status not in VALID_STATUS: raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}") row: Dict[str, Any] = { "created_by": user_id, "institute_id": institute_id, "title": body.title, "status": body.status or "draft", "objectives": body.objectives or [], "activities": body.activities or [], "tags": body.tags or [], } for field in ( "class_id", "subject", "year_group", "estimated_duration_minutes", "topic_code", "whiteboard_room_id", "course_id", "sequence_number", ): val = getattr(body, field) if val is not None: row[field] = val res = sb.supabase.table("planned_lessons").insert(row).execute() plan = (res.data or [{}])[0] logger.info(f"Lesson plan created: {plan.get('id')} '{body.title}' by {user_id}") return plan @router.get("/plans/{plan_id}") async def get_plan( plan_id: str, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() plan_res = ( sb.supabase.table("planned_lessons") .select("*") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Lesson plan not found") plan = plan_res.data if not _is_creator(plan, user_id): collab_check = ( sb.supabase.table("lesson_collaborators") .select("can_edit") .eq("planned_lesson_id", plan_id) .eq("profile_id", user_id) .execute() .data ) if collab_check is None: raise HTTPException(status_code=403, detail="Access denied") collab_rows = ( sb.supabase.table("lesson_collaborators") .select("profile_id, can_edit, added_at") .eq("planned_lesson_id", plan_id) .execute() .data or [] ) profile_ids = [r["profile_id"] for r in collab_rows] profile_map: Dict[str, Dict] = {} if profile_ids: try: profiles = ( sb.supabase.table("profiles") .select("id, full_name, email") .in_("id", profile_ids) .execute() .data or [] ) profile_map = {p["id"]: p for p in profiles} except Exception: pass collaborators = [ { **r, "full_name": profile_map.get(r["profile_id"], {}).get("full_name"), "email": profile_map.get(r["profile_id"], {}).get("email"), } for r in collab_rows ] deliveries = ( sb.supabase.table("lesson_deliveries") .select("*") .eq("planned_lesson_id", plan_id) .order("started_at", desc=True) .limit(10) .execute() .data or [] ) return {**plan, "collaborators": collaborators, "recent_deliveries": deliveries} @router.patch("/plans/{plan_id}") async def update_plan( plan_id: str, body: UpdatePlanRequest, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() if not _can_edit_plan(sb, plan_id, user_id): raise HTTPException(status_code=403, detail="Access denied — not creator or editor collaborator") if body.status and body.status not in VALID_STATUS: raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}") updates: Dict[str, Any] = {} for field in ( "title", "class_id", "subject", "year_group", "estimated_duration_minutes", "objectives", "activities", "status", "tags", "topic_code", "whiteboard_room_id", "course_id", "sequence_number", ): val = getattr(body, field) if val is not None: updates[field] = val if not updates: raise HTTPException(status_code=400, detail="Nothing to update") updates["updated_at"] = datetime.utcnow().isoformat() res = ( sb.supabase.table("planned_lessons") .update(updates) .eq("id", plan_id) .eq("institute_id", institute_id) .execute() ) if not res.data: raise HTTPException(status_code=404, detail="Plan not found") return res.data[0] @router.delete("/plans/{plan_id}") async def delete_plan( plan_id: str, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() plan_res = ( sb.supabase.table("planned_lessons") .select("created_by") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Plan not found") if not _is_creator(plan_res.data, user_id): raise HTTPException(status_code=403, detail="Only the creator can delete a plan") sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).execute() sb.supabase.table("planned_lessons").delete().eq("id", plan_id).execute() logger.info(f"Lesson plan deleted: {plan_id} by {user_id}") return {"status": "ok"} @router.post("/plans/{plan_id}/deliver") async def deliver_plan( plan_id: str, body: DeliverPlanRequest, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() plan_res = ( sb.supabase.table("planned_lessons") .select("id, whiteboard_room_id") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Plan not found") if not _can_edit_plan(sb, plan_id, user_id): raise HTTPException(status_code=403, detail="Access denied") row: Dict[str, Any] = { "planned_lesson_id": plan_id, "delivered_by": user_id, "institute_id": institute_id, "started_at": datetime.utcnow().isoformat(), } if body.taught_lesson_id: row["taught_lesson_id"] = body.taught_lesson_id if body.class_id: row["class_id"] = body.class_id if body.notes: row["notes"] = body.notes whiteboard_room_id = plan_res.data.get("whiteboard_room_id") if whiteboard_room_id: row["whiteboard_room_id"] = whiteboard_room_id res = sb.supabase.table("lesson_deliveries").insert(row).execute() delivery = (res.data or [{}])[0] logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}") return delivery @router.get("/plans/{plan_id}/deliveries") async def list_deliveries( plan_id: str, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() plan_res = ( sb.supabase.table("planned_lessons") .select("created_by") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Plan not found") deliveries = ( sb.supabase.table("lesson_deliveries") .select("*") .eq("planned_lesson_id", plan_id) .order("started_at", desc=True) .execute() .data or [] ) return {"deliveries": deliveries} @router.post("/plans/{plan_id}/collaborators") async def add_collaborator( plan_id: str, body: AddCollaboratorRequest, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() plan_res = ( sb.supabase.table("planned_lessons") .select("created_by") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Plan not found") if not _is_creator(plan_res.data, user_id): raise HTTPException(status_code=403, detail="Only the creator can add collaborators") res = ( sb.supabase.table("lesson_collaborators") .upsert( { "planned_lesson_id": plan_id, "profile_id": body.profile_id, "can_edit": body.can_edit, "added_at": datetime.utcnow().isoformat(), }, on_conflict="planned_lesson_id,profile_id", ) .execute() ) return {"status": "ok", "row": (res.data or [{}])[0]} @router.delete("/plans/{plan_id}/collaborators/{profile_id}") async def remove_collaborator( plan_id: str, profile_id: str, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() plan_res = ( sb.supabase.table("planned_lessons") .select("created_by") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Plan not found") if not _is_creator(plan_res.data, user_id): raise HTTPException(status_code=403, detail="Only the creator can remove collaborators") sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).eq("profile_id", profile_id).execute() return {"status": "ok"} @router.post("/plans/{plan_id}/suggest") async def suggest_field( plan_id: str, body: SuggestRequest, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") institute_id = _require_institute(user_id) sb = _sb() if body.field not in VALID_FIELDS: raise HTTPException(status_code=400, detail=f"field must be one of {VALID_FIELDS}") plan_res = ( sb.supabase.table("planned_lessons") .select("*") .eq("id", plan_id) .eq("institute_id", institute_id) .single() .execute() ) if not plan_res.data: raise HTTPException(status_code=404, detail="Plan not found") if not _can_edit_plan(sb, plan_id, user_id): raise HTTPException(status_code=403, detail="Access denied") plan = plan_res.data subject = plan.get("subject") or "a UK secondary school subject" year_group = plan.get("year_group") or "unspecified year group" title = plan.get("title", "") context = body.context or "" context_part = ("Teacher notes: " + context + "\n") if context else "" if body.field == "objectives": existing = "" if body.objective_texts: existing = "\n".join("- " + t for t in body.objective_texts) objectives_part = ("Existing objectives:\n" + existing + "\n") if existing else "" prompt = ( "You are an expert UK secondary school teacher.\n" "Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n" + objectives_part + context_part + "Suggest ONE clear, measurable learning objective for this lesson using Bloom's taxonomy. " "Write only the objective text, no bullet point or prefix." ) elif body.field == "activity_description": section = body.activity_section or "Main" prompt = ( "You are an expert UK secondary school teacher.\n" "Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n" "Activity section: " + section + ".\n" + context_part + "Write a concise, practical description (2-4 sentences) for a " + section + " activity " "suitable for UK secondary pupils. Include what the teacher does and what pupils do." ) else: # title prompt = ( "You are an expert UK secondary school teacher.\n" "Subject: " + subject + ", Year: " + year_group + ".\n" + context_part + "Suggest ONE engaging, clear lesson title for a UK secondary school lesson. " "Write only the title, no explanation." ) try: payload = { "model": _OLLAMA_MODEL, "prompt": prompt, "stream": False, } async with aiohttp.ClientSession() as session: async with session.post( f"{_OLLAMA_URL}/api/generate", json=payload, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=_LLM_TIMEOUT), ) as resp: if resp.status != 200: body_text = await resp.text() logger.error(f"Ollama suggest error ({resp.status}): {body_text}") raise HTTPException(status_code=502, detail=f"LLM request failed: {resp.status}") data = await resp.json() suggestion = (data.get("response") or "").strip() except HTTPException: raise except Exception as e: logger.error(f"suggest_field LLM call failed for plan {plan_id}: {e}") raise HTTPException(status_code=502, detail="LLM service unavailable") return {"suggestion": suggestion}