api/routers/database/tools/taught_lessons_router.py
kcar abf8d05ca1 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 <noreply@anthropic.com>
2026-05-27 02:55:44 +01:00

602 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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