api/routers/database/tools/lesson_plans_router.py
kcar 52532ce00f feat(phase-c): lesson plans library backend — CRUD, delivery linking, AI suggest
Adds lesson_plans_router.py with 10 endpoints under /lessons/plans:
GET/POST /plans, GET/PATCH/DELETE /plans/{id}, POST /plans/{id}/deliver,
GET /plans/{id}/deliveries, POST/DELETE /plans/{id}/collaborators,
POST /plans/{id}/suggest (Ollama-backed per-field AI suggestions).

objectives and activities stored as JSONB arrays with Bloom taxonomy support.
Registers router in run/routers.py. Adds seed_test_environment.py for
platform-admin triggered reset + seed of demo users and Neo4j.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:59:26 +01:00

651 lines
21 KiB
Python

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}