feat(phase-b): school/timetable API routers + graph nav tree
New routers (all previously untracked): - graph_tree_router: /graph/tree, /graph/node/children, /graph/calendar/academic Supabase-driven tree builder; institute DB resolved by teacher email scan - school_router: /school/status (role + calendar flags), /school/info PATCH Self-heals profiles.school_id from institute_memberships if null - timetable_builder_router: /timetable/setup (AcademicYear/Term/Week + SchoolTimetable), /timetable/slots (read/write TimetableSlot nodes), /timetable/init (TeacherTimetable) - user_init_router: /user/init (provision user node in institute DB) routers.py: register all new routers with correct prefixes users.py: add JournalNode and PlannerNode schema classes Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
School Router — school status, role, and admin-editable info.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
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)}
|
||||
Reference in New Issue
Block a user