""" Timetable Builder Router Endpoints for setting up academic year structure and teacher timetable slots. """ import os import json from datetime import datetime, timedelta, date from typing import Dict, Any, List, Optional from fastapi import APIRouter, Depends from pydantic import BaseModel from modules.logger_tool import initialise_logger from modules.auth.supabase_bearer import SupabaseBearer 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 _find_teacher_institute(user_email: str): if not user_email: return None, None 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 and rec["uuid"]: return db, rec["uuid"] except Exception: continue except Exception as e: logger.warning(f"Institute lookup failed: {e}") return None, None def _iso_date(s: str) -> date: return datetime.strptime(s, "%Y-%m-%d").date() def _academic_weeks(term_start: date, term_end: date): # First Monday >= term_start current = term_start - timedelta(days=term_start.weekday()) if current < term_start: current += timedelta(weeks=1) n = 1 while current <= term_end: yield n, current current += timedelta(weeks=1) n += 1 # ─── Request models ─────────────────────────────────────────────────────────── class TermInput(BaseModel): name: str term_number: int start_date: str end_date: str class PeriodInput(BaseModel): code: str name: str start_time: str end_time: str period_type: str # lesson | break | registration class TimetableSetupRequest(BaseModel): year_start: str year_end: str terms: List[TermInput] periods: List[PeriodInput] class SlotInput(BaseModel): day_of_week: str period_code: str subject_class: str start_time: str end_time: str class SlotsRequest(BaseModel): timetable_id: str slots: List[SlotInput] # ─── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/status") async def get_timetable_status(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: """Check whether the teacher has a timetable set up.""" user_email = credentials.get("email", "") institute_db, teacher_uuid = _find_teacher_institute(user_email) if not institute_db: return {"status": "no_school"} try: with driver_tools.get_session(database=institute_db) as s: has_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() has_school_tt = s.run( "MATCH (st:SchoolTimetable) RETURN st.uuid_string AS id LIMIT 1" ).single() return { "status": "ok", "has_teacher_timetable": has_tt is not None, "timetable_id": has_tt["id"] if has_tt else None, "has_school_timetable": has_school_tt is not None, "institute_db": institute_db, } except Exception as e: return {"status": "error", "message": str(e)} @router.post("/setup") async def setup_timetable( body: TimetableSetupRequest, credentials: dict = Depends(SupabaseBearer()) ) -> Dict[str, Any]: """Create academic year/term/week structure + TeacherTimetable in the institute DB.""" user_email = credentials.get("email", "") user_id = credentials.get("sub", "") institute_db, teacher_uuid = _find_teacher_institute(user_email) # school_admin may have no Teacher node — fall back to Supabase for institute_db if not institute_db and user_id: try: from modules.database.supabase.utils.client import SupabaseServiceRoleClient _sb = SupabaseServiceRoleClient() _p = _sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() _sid = (_p.data or {}).get("school_id") if _sid: _i = _sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", _sid).single().execute() _uuid = (_i.data or {}).get("neo4j_uuid_string") if _uuid: institute_db = f"cc.institutes.{_uuid}" except Exception as _e: logger.warning(f"Supabase institute fallback failed: {_e}") if not institute_db: return {"status": "error", "message": "Institute database not found"} year_start = _iso_date(body.year_start) year_end = _iso_date(body.year_end) year_label = f"{year_start.year}-{year_end.year}" school_tt_id = f"school_tt_{year_start.year}_{year_end.year}" ay_id = f"academic_year_{year_start.year}_{year_end.year}" try: with driver_tools.get_session(database=institute_db) as s: # SchoolTimetable s.run(""" MERGE (tt:SchoolTimetable {uuid_string: $id}) SET tt.school_timetable_id = $id, tt.start_date = date($start), tt.end_date = date($end), tt.node_storage_path = $path, tt.periods_template = $periods WITH tt MATCH (sch:School) MERGE (sch)-[:HAS_TIMETABLE]->(tt) """, id=school_tt_id, start=body.year_start, end=body.year_end, path=f"timetable/{school_tt_id}", periods=json.dumps([p.dict() for p in body.periods])) # AcademicYear s.run(""" MERGE (ay:AcademicYear {uuid_string: $id}) SET ay.year = $year, ay.node_storage_path = $path WITH ay MATCH (tt:SchoolTimetable {uuid_string: $tt_id}) MERGE (tt)-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay) """, id=ay_id, year=year_label, path=f"timetable/{ay_id}", tt_id=school_tt_id) # Terms + weeks for term in body.terms: t_id = f"term_{year_start.year}_{term.term_number}" t_start = _iso_date(term.start_date) t_end = _iso_date(term.end_date) s.run(""" MERGE (t:AcademicTerm {uuid_string: $id}) SET t.term_name = $name, t.term_number = $num, t.start_date = date($start), t.end_date = date($end), t.node_storage_path = $path WITH t MATCH (ay:AcademicYear {uuid_string: $ay_id}) MERGE (ay)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t) """, id=t_id, name=term.name, num=str(term.term_number), start=term.start_date, end=term.end_date, path=f"timetable/{t_id}", ay_id=ay_id) for wn, wstart in _academic_weeks(t_start, t_end): w_id = f"week_{t_id}_{wn}" s.run(""" MERGE (w:AcademicWeek {uuid_string: $id}) SET w.academic_week_number = $num, w.start_date = date($start), w.week_type = 'academic', w.node_storage_path = $path WITH w MATCH (t:AcademicTerm {uuid_string: $t_id}) MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w) """, id=w_id, num=str(wn), start=wstart.isoformat(), path=f"timetable/{w_id}", t_id=t_id) # TeacherTimetable — only if user has a Teacher node if teacher_uuid: teacher_tt_id = f"teacher_tt_{teacher_uuid}_{year_start.year}" s.run(""" MERGE (tt:TeacherTimetable {uuid_string: $id}) SET tt.teacher_timetable_id = $id, tt.start_date = date($start), tt.end_date = date($end), tt.node_storage_path = $path WITH tt MATCH (teacher:Teacher {uuid_string: $t_uuid}) MERGE (teacher)-[:HAS_TIMETABLE]->(tt) WITH tt MATCH (st:SchoolTimetable {uuid_string: $st_id}) MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st) """, id=teacher_tt_id, start=body.year_start, end=body.year_end, path=f"timetable/{teacher_tt_id}", t_uuid=teacher_uuid, st_id=school_tt_id) else: teacher_tt_id = None return { "status": "ok", "timetable_id": teacher_tt_id, "school_timetable_id": school_tt_id, "institute_db": institute_db, } except Exception as e: logger.error(f"Timetable setup failed: {e}") return {"status": "error", "message": str(e)} @router.get("/slots") async def get_timetable_slots(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: user_email = credentials.get("email", "") institute_db, teacher_uuid = _find_teacher_institute(user_email) if not institute_db: return {"status": "no_school", "slots": [], "periods": []} try: with driver_tools.get_session(database=institute_db) as s: tt_rec = 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 not tt_rec: return {"status": "empty", "slots": [], "periods": []} tt_id = tt_rec["id"] slots_result = s.run( "MATCH (:TeacherTimetable {uuid_string: $id})-[:HAS_TIMETABLE_SLOT]->(sl:TimetableSlot) " "RETURN sl", id=tt_id ) slots = [ {"day_of_week": r["sl"]["day_of_week"], "period_code": r["sl"]["period_code"], "subject_class": r["sl"]["subject_class"], "start_time": r["sl"]["start_time"], "end_time": r["sl"]["end_time"]} for r in slots_result ] periods_rec = s.run( "MATCH (st:SchoolTimetable) WHERE st.periods_template IS NOT NULL " "RETURN st.periods_template AS p LIMIT 1" ).single() periods = json.loads(periods_rec["p"]) if periods_rec else [] return {"status": "ok", "timetable_id": tt_id, "slots": slots, "periods": periods, "institute_db": institute_db} except Exception as e: logger.error(f"Get timetable slots failed: {e}") return {"status": "error", "slots": [], "periods": []} @router.post("/slots") async def save_timetable_slots( body: SlotsRequest, credentials: dict = Depends(SupabaseBearer()) ) -> Dict[str, Any]: user_email = credentials.get("email", "") institute_db, _ = _find_teacher_institute(user_email) if not institute_db: return {"status": "error", "message": "Teacher not linked to school"} try: with driver_tools.get_session(database=institute_db) as s: s.run( "MATCH (:TeacherTimetable {uuid_string: $id})-[:HAS_TIMETABLE_SLOT]->(sl) " "DETACH DELETE sl", id=body.timetable_id ) created = 0 for slot in body.slots: if not slot.subject_class.strip(): continue slot_id = f"slot_{body.timetable_id}_{slot.day_of_week}_{slot.period_code}" s.run(""" MERGE (sl:TimetableSlot {uuid_string: $id}) SET sl.day_of_week = $day, sl.period_code = $code, sl.subject_class = $cls, sl.start_time = $start, sl.end_time = $end, sl.node_storage_path = $path WITH sl MATCH (tt:TeacherTimetable {uuid_string: $tt_id}) MERGE (tt)-[:HAS_TIMETABLE_SLOT]->(sl) """, id=slot_id, day=slot.day_of_week, code=slot.period_code, cls=slot.subject_class, start=slot.start_time, end=slot.end_time, path=f"timetable/slots/{slot_id}", tt_id=body.timetable_id) created += 1 return {"status": "ok", "created": created} except Exception as e: logger.error(f"Save timetable slots failed: {e}") return {"status": "error", "message": str(e)} @router.post("/init") async def init_teacher_timetable(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: """Create a TeacherTimetable for the current teacher using the existing school calendar.""" user_email = credentials.get("email", "") institute_db, teacher_uuid = _find_teacher_institute(user_email) if not institute_db: return {"status": "error", "message": "Teacher not linked to a school"} try: with driver_tools.get_session(database=institute_db) as s: st_rec = s.run( "MATCH (st:SchoolTimetable) " "RETURN st.uuid_string AS id, " " toString(st.start_date) AS start, " " toString(st.end_date) AS end " "LIMIT 1" ).single() if not st_rec: return {"status": "error", "message": "No school calendar set up yet — contact your school admin"} school_tt_id = st_rec["id"] start_str = str(st_rec["start"]) end_str = str(st_rec["end"]) year_str = start_str[:4] teacher_tt_id = f"teacher_tt_{teacher_uuid}_{year_str}" s.run(""" MERGE (tt:TeacherTimetable {uuid_string: $id}) SET tt.teacher_timetable_id = $id, tt.start_date = date($start), tt.end_date = date($end), tt.node_storage_path = $path WITH tt MATCH (teacher:Teacher {uuid_string: $t_uuid}) MERGE (teacher)-[:HAS_TIMETABLE]->(tt) WITH tt MATCH (st:SchoolTimetable {uuid_string: $st_id}) MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st) """, id=teacher_tt_id, start=start_str, end=end_str, path=f"timetable/{teacher_tt_id}", t_uuid=teacher_uuid, st_id=school_tt_id) return {"status": "ok", "timetable_id": teacher_tt_id} except Exception as e: logger.error(f"Teacher timetable init failed: {e}") return {"status": "error", "message": str(e)}