- 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>
602 lines
21 KiB
Python
602 lines
21 KiB
Python
"""
|
||
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),
|
||
}
|