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 <[email protected]>
This commit is contained in:
@@ -318,3 +318,286 @@ def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: s
|
||||
"institute_id": school_id,
|
||||
"role": role,
|
||||
}).execute()
|
||||
|
||||
|
||||
# ─── School Overview ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_school_overview(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Summary dashboard for school admins: staff/student/class counts,
|
||||
calendar snapshot (terms, total academic days, current/next term).
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
# Role check
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
user_role = (mem.data or {}).get("role", "teacher")
|
||||
|
||||
# Counts
|
||||
staff_roles = ["teacher", "school_admin", "department_head"]
|
||||
staff_rows = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.in_("role", staff_roles)
|
||||
.execute()
|
||||
)
|
||||
student_rows = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("role", "student")
|
||||
.execute()
|
||||
)
|
||||
class_rows = (
|
||||
sb.supabase.table("classes")
|
||||
.select("id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("is_active", True)
|
||||
.execute()
|
||||
)
|
||||
|
||||
# Calendar snapshot from academic_terms
|
||||
terms = (
|
||||
sb.supabase.table("academic_terms")
|
||||
.select("id,term_name,term_number,start_date,end_date")
|
||||
.eq("institute_id", school_id)
|
||||
.order("term_number")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
# Academic day counts per term
|
||||
if terms:
|
||||
term_ids = [t["id"] for t in terms]
|
||||
day_counts_res = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("academic_term_id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("day_type", "Academic")
|
||||
.in_("academic_term_id", term_ids)
|
||||
.execute()
|
||||
)
|
||||
# Supabase doesn't group-by server-side; count manually per term
|
||||
all_days = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("academic_term_id,day_type")
|
||||
.eq("institute_id", school_id)
|
||||
.in_("academic_term_id", term_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import defaultdict
|
||||
academic_day_count: Dict[str, int] = defaultdict(int)
|
||||
total_day_count: Dict[str, int] = defaultdict(int)
|
||||
for d in all_days:
|
||||
total_day_count[d["academic_term_id"]] += 1
|
||||
if d["day_type"] == "Academic":
|
||||
academic_day_count[d["academic_term_id"]] += 1
|
||||
|
||||
from datetime import date
|
||||
today_str = str(date.today())
|
||||
for t in terms:
|
||||
t["academic_days"] = academic_day_count.get(t["id"], 0)
|
||||
t["total_days"] = total_day_count.get(t["id"], 0)
|
||||
if t["start_date"] <= today_str <= t["end_date"]:
|
||||
t["is_current"] = True
|
||||
else:
|
||||
t["is_current"] = False
|
||||
|
||||
pending_invites = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id", count="exact")
|
||||
.eq("institute_id", school_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_role": user_role,
|
||||
"counts": {
|
||||
"staff": staff_rows.count or 0,
|
||||
"students": student_rows.count or 0,
|
||||
"classes": class_rows.count or 0,
|
||||
"pending_invitations": pending_invites.count or 0,
|
||||
},
|
||||
"terms": terms,
|
||||
"has_calendar": len(terms) > 0,
|
||||
}
|
||||
|
||||
|
||||
# ─── Calendar days (admin view) ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/calendar/days")
|
||||
async def list_calendar_days(
|
||||
term_id: Optional[str] = None,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Return academic_days for the school, optionally filtered by term.
|
||||
Includes week_cycle from the parent academic_week.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
q = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("id,date,day_of_week,day_type,academic_week_id,academic_term_id,academic_day_number,excluded_period_codes")
|
||||
.eq("institute_id", school_id)
|
||||
.order("date")
|
||||
)
|
||||
if term_id:
|
||||
q = q.eq("academic_term_id", term_id)
|
||||
|
||||
days = q.execute().data or []
|
||||
|
||||
# Enrich with week_cycle
|
||||
if days:
|
||||
week_ids = list({d["academic_week_id"] for d in days if d.get("academic_week_id")})
|
||||
weeks = (
|
||||
sb.supabase.table("academic_weeks")
|
||||
.select("id,week_number,week_cycle")
|
||||
.in_("id", week_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
wk_map = {w["id"]: w for w in weeks}
|
||||
for d in days:
|
||||
wk = wk_map.get(d.get("academic_week_id", ""), {})
|
||||
d["week_cycle"] = wk.get("week_cycle", "")
|
||||
d["week_number"] = wk.get("week_number")
|
||||
|
||||
return {"status": "ok", "days": days, "total": len(days)}
|
||||
|
||||
|
||||
@router.patch("/calendar/days/{day_id}")
|
||||
async def update_calendar_day(
|
||||
day_id: str,
|
||||
body: Dict[str, Any],
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Override day_type for a single academic day (school admin only).
|
||||
Syncs academic_periods: removes periods for non-Academic days,
|
||||
creates periods from periods_template for newly-Academic days.
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
sb = _get_sb()
|
||||
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
school_id = str((p.data or {}).get("school_id") or "")
|
||||
if not school_id:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
|
||||
# Verify admin role
|
||||
mem = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if (mem.data or {}).get("role") not in ("school_admin", "department_head"):
|
||||
raise HTTPException(status_code=403, detail="School admin access required")
|
||||
|
||||
# Verify day belongs to school
|
||||
day = (
|
||||
sb.supabase.table("academic_days")
|
||||
.select("*")
|
||||
.eq("id", day_id)
|
||||
.eq("institute_id", school_id)
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Day not found")
|
||||
|
||||
new_day_type = body.get("day_type", day["day_type"])
|
||||
excluded = body.get("excluded_period_codes", day.get("excluded_period_codes") or [])
|
||||
|
||||
valid_types = {"Academic", "Holiday", "Staff", "OffTimetable"}
|
||||
if new_day_type not in valid_types:
|
||||
raise HTTPException(status_code=400, detail=f"day_type must be one of {sorted(valid_types)}")
|
||||
|
||||
# Update the day
|
||||
sb.supabase.table("academic_days").update({
|
||||
"day_type": new_day_type,
|
||||
"excluded_period_codes": excluded,
|
||||
}).eq("id", day_id).execute()
|
||||
|
||||
old_type = day["day_type"]
|
||||
periods_changed = 0
|
||||
|
||||
if old_type == "Academic" and new_day_type != "Academic":
|
||||
# Remove periods for this day
|
||||
del_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.delete()
|
||||
.eq("academic_day_id", day_id)
|
||||
.execute()
|
||||
)
|
||||
periods_changed = -(len(del_res.data or []))
|
||||
|
||||
elif old_type != "Academic" and new_day_type == "Academic":
|
||||
# Create periods from template
|
||||
stt = (
|
||||
sb.supabase.table("school_timetables")
|
||||
.select("periods_template")
|
||||
.eq("institute_id", school_id)
|
||||
.order("created_at", desc=True)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
template = (stt[0].get("periods_template") or []) if stt else []
|
||||
skip = set(excluded)
|
||||
new_periods = []
|
||||
for period in template:
|
||||
if period.get("code") in skip:
|
||||
continue
|
||||
new_periods.append({
|
||||
"academic_day_id": day_id,
|
||||
"institute_id": school_id,
|
||||
"period_code": period["code"],
|
||||
"period_name": period.get("name", period["code"]),
|
||||
"start_time": period.get("start_time"),
|
||||
"end_time": period.get("end_time"),
|
||||
"period_type": period.get("period_type", "lesson"),
|
||||
})
|
||||
if new_periods:
|
||||
ins_res = (
|
||||
sb.supabase.table("academic_periods")
|
||||
.upsert(new_periods, on_conflict="academic_day_id,period_code")
|
||||
.execute()
|
||||
)
|
||||
periods_changed = len(ins_res.data or [])
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"day_id": day_id,
|
||||
"new_day_type": new_day_type,
|
||||
"periods_changed": periods_changed,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user