""" School Router — school status, search, register, and admin-editable info. """ import os import json import uuid as uuid_lib from typing import Dict, Any, Optional, List from fastapi import APIRouter, Depends, Query 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 import modules.database.tools.neo4j_driver_tools as driver_tools logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) router = APIRouter() # ─── Helpers ────────────────────────────────────────────────────────────────── def _get_sb(): return SupabaseServiceRoleClient() def _institute_db(neo4j_uuid: str) -> str: return f"cc.institutes.{neo4j_uuid}" def _find_institute_db_by_email(user_email: str) -> Optional[str]: """Fallback: scan all institute DBs for the teacher's email.""" try: with driver_tools.get_session(database="system") as s: dbs = [r["name"] for r in s.run( "SHOW DATABASES YIELD name " "WHERE name STARTS WITH 'cc.institutes.' " "AND NOT name ENDS WITH '.curriculum' RETURN name" )] for db in dbs: try: with driver_tools.get_session(database=db) as s: rec = s.run( "MATCH (t:Teacher) WHERE t.worker_email = $e " "RETURN t.uuid_string AS uuid LIMIT 1", e=user_email ).single() if rec: return db except Exception: continue except Exception as e: logger.warning(f"Institute DB scan failed: {e}") return None # ─── Status endpoint ───────────────────────────────────────────────────────── @router.get("/status") async def get_school_status(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: """Return the current user's school role, school info, and calendar/timetable setup status.""" user_id = credentials.get("sub", "") user_email = credentials.get("email", "") try: sb = _get_sb() p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() school_id = (p.data or {}).get("school_id") # Fallback: if profiles.school_id not set, check institute_memberships directly if not school_id: m_fb = sb.supabase.table("institute_memberships").select("institute_id,role").eq("profile_id", user_id).single().execute() if m_fb.data and m_fb.data.get("institute_id"): school_id = m_fb.data["institute_id"] user_role = m_fb.data.get("role") or "teacher" # Self-heal: write school_id back to profile try: sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute() except Exception: pass else: return {"status": "no_school"} else: m = sb.supabase.table("institute_memberships").select("role").eq("profile_id", user_id).eq("institute_id", school_id).single().execute() user_role = ((m.data or {}).get("role") or "teacher") i = sb.supabase.table("institutes").select("id,name,urn,website,address,metadata,neo4j_uuid_string").eq("id", school_id).single().execute() inst = i.data or {} neo4j_uuid = inst.get("neo4j_uuid_string") meta = inst.get("metadata") or {} except Exception as e: logger.warning(f"Supabase school status query failed: {e}") return {"status": "error", "message": str(e)} if neo4j_uuid: inst_db = _institute_db(neo4j_uuid) else: inst_db = _find_institute_db_by_email(user_email) if not inst_db: return {"status": "no_school"} school_has_calendar = False teacher_has_timetable = False timetable_id = None periods_template = None try: with driver_tools.get_session(database=inst_db) as s: ay = s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single() school_has_calendar = ay is not None st = s.run( "MATCH (st:SchoolTimetable) WHERE st.periods_template IS NOT NULL " "RETURN st.periods_template AS p LIMIT 1" ).single() if st: periods_template = json.loads(st["p"]) t = s.run( "MATCH (t:Teacher) WHERE t.worker_email = $e " "RETURN t.uuid_string AS uuid LIMIT 1", e=user_email ).single() if t: teacher_uuid = t["uuid"] tt = s.run( "MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) " "RETURN tt.uuid_string AS id LIMIT 1", u=teacher_uuid ).single() if tt: teacher_has_timetable = True timetable_id = tt["id"] except Exception as e: logger.warning(f"Neo4j school status check failed for {inst_db}: {e}") return { "status": "ok", "user_role": user_role, "school_id": str(school_id), "institute_db": inst_db, "school_has_calendar": school_has_calendar, "teacher_has_timetable": teacher_has_timetable, "timetable_id": timetable_id, "periods_template": periods_template, "school_info": { "name": inst.get("name", ""), "urn": inst.get("urn", ""), "website": inst.get("website", ""), "address": inst.get("address") or {}, "headteacher": meta.get("headteacher", ""), "term_dates_url": meta.get("term_dates_url", ""), "staff_list_url": meta.get("staff_list_url", ""), }, } # ─── School info update ─────────────────────────────────────────────────────── class SchoolInfoUpdate(BaseModel): headteacher: Optional[str] = None term_dates_url: Optional[str] = None staff_list_url: Optional[str] = None @router.patch("/info") async def update_school_info( body: SchoolInfoUpdate, credentials: dict = Depends(SupabaseBearer()) ) -> Dict[str, Any]: """Admin: update school metadata fields stored in institutes.metadata jsonb.""" user_id = credentials.get("sub", "") try: sb = _get_sb() p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() school_id = (p.data or {}).get("school_id") if not school_id: return {"status": "error", "message": "No school linked"} m = sb.supabase.table("institute_memberships").select("role").eq("profile_id", user_id).eq("institute_id", school_id).single().execute() role = ((m.data or {}).get("role") or "teacher") if role != "school_admin": return {"status": "error", "message": "school_admin role required"} i = sb.supabase.table("institutes").select("metadata").eq("id", school_id).single().execute() meta = dict((i.data or {}).get("metadata") or {}) if body.headteacher is not None: meta["headteacher"] = body.headteacher if body.term_dates_url is not None: meta["term_dates_url"] = body.term_dates_url if body.staff_list_url is not None: meta["staff_list_url"] = body.staff_list_url sb.supabase.table("institutes").update({"metadata": meta}).eq("id", school_id).execute() return {"status": "ok"} except Exception as e: logger.error(f"School info update failed: {e}") return {"status": "error", "message": str(e)} # ─── GAIS school search ─────────────────────────────────────────────────────── @router.get("/search") async def search_schools( q: str = Query(..., min_length=2, description="School name, URN, or postcode"), limit: int = Query(20, ge=1, le=100), status: str = Query("Open", description="Filter by establishment status"), credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: """Search GAIS school reference data by name, URN, or postcode.""" try: sb = _get_sb() query = sb.supabase.table("gais_schools").select( "urn,name,status,phase,type,street,locality,town,county,postcode," "website,telephone,head_title,head_first_name,head_last_name," "la_code,la_name,number_of_pupils,gender,religious_character,region" ) if status: query = query.eq("status", status) q_stripped = q.strip() if q_stripped.isdigit(): # URN exact match query = query.eq("urn", q_stripped) else: # Name / postcode trigram search — use ilike on name first, fallback includes postcode query = query.ilike("name", f"%{q_stripped}%") result = query.limit(limit).execute() return {"status": "ok", "schools": result.data or [], "count": len(result.data or [])} except Exception as e: logger.error(f"School search failed: {e}") return {"status": "error", "message": str(e)} # ─── School registration (onboarding) ──────────────────────────────────────── class SchoolRegisterBody(BaseModel): urn: str name: str address: Optional[Dict[str, Any]] = None website: Optional[str] = None headteacher: Optional[str] = None @router.post("/register") async def register_school( body: SchoolRegisterBody, credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: """ Onboarding: create an institute record from a GAIS-selected school, provision the Neo4j database, and link the calling user as school_admin. """ user_id = credentials.get("sub", "") user_email = credentials.get("email", "") try: sb = _get_sb() # Prevent duplicate registration existing = sb.supabase.table("institutes").select("id").eq("urn", body.urn).execute() if existing.data: school_id = existing.data[0]["id"] # Still link user if not already linked _ensure_membership(sb, user_id, school_id, "school_admin") sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute() return {"status": "already_exists", "school_id": str(school_id)} # Build metadata meta: Dict[str, Any] = {} if body.headteacher: meta["headteacher"] = body.headteacher institute_data: Dict[str, Any] = { "name": body.name, "urn": body.urn, "status": "active", "address": body.address or {}, "website": body.website or "", "metadata": meta, } ins_result = sb.supabase.table("institutes").insert(institute_data).execute() if not ins_result.data: return {"status": "error", "message": "Failed to create institute record"} school_id = ins_result.data[0]["id"] # Provision Neo4j institute database try: from modules.database.services.provisioning_service import ProvisioningService ps = ProvisioningService() ps.ensure_school(school_id) # Reload institute to get the neo4j_uuid_string set by provisioning inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute() neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string") except Exception as prov_err: logger.warning(f"Neo4j provisioning failed for {school_id}: {prov_err}") neo4j_uuid = None # Link user as school_admin _ensure_membership(sb, user_id, school_id, "school_admin") sb.supabase.table("profiles").update({"school_id": str(school_id)}).eq("id", user_id).execute() return { "status": "ok", "school_id": str(school_id), "neo4j_uuid": neo4j_uuid, } except Exception as e: logger.error(f"School registration failed: {e}") return {"status": "error", "message": str(e)} def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: str, role: str) -> None: existing = sb.supabase.table("institute_memberships").select("id").eq("profile_id", user_id).eq("institute_id", school_id).execute() if not existing.data: sb.supabase.table("institute_memberships").insert({ "profile_id": user_id, "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, }