import os from typing import Dict, Any, List, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException from modules.logger_tool import initialise_logger from modules.auth.supabase_bearer import SupabaseBearer import modules.database.tools.neo4j_driver_tools as driver_tools from modules.database.supabase.utils.client import SupabaseServiceRoleClient logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) router = APIRouter() # ─── DB helpers ──────────────────────────────────────────────────────────────── def _user_to_teacher_db(user_id: str) -> str: return f"cc.users.teacher.{user_id.replace('-', '')}" def _sb() -> SupabaseServiceRoleClient: return SupabaseServiceRoleClient() def _find_teacher_uuid(db: str, user_email: str) -> Optional[str]: """Query teacher UUID from a known Neo4j institute DB.""" try: with driver_tools.get_session(database=db) as session: rec = session.run( 'MATCH (t:Teacher) WHERE t.worker_email = $email ' 'RETURN t.uuid_string AS uuid LIMIT 1', email=user_email, ).single() if rec: return rec['uuid'] except Exception: pass return None def _resolve_institute( user_id: str, user_email: str ) -> tuple: """Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid). Supabase-first lookup with Neo4j email-scan fallback.""" try: sb = _sb() p = sb.supabase.table('profiles').select('school_id').eq('id', user_id).single().execute() school_id = (p.data or {}).get('school_id') if school_id: i = sb.supabase.table('institutes').select('id,neo4j_uuid_string').eq('id', str(school_id)).single().execute() inst = i.data or {} neo4j_uuid = inst.get('neo4j_uuid_string') if neo4j_uuid: db = f'cc.institutes.{neo4j_uuid}' teacher_uuid = _find_teacher_uuid(db, user_email) return str(school_id), db, teacher_uuid except Exception as e: logger.warning(f'Supabase-first institute resolve failed: {e}') # Fallback: scan Neo4j db, teacher_uuid = _find_teacher_institute(user_email) return None, db, teacher_uuid def _find_teacher_institute(user_email: str) -> Tuple[Optional[str], Optional[str]]: """Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs.""" if not user_email: return None, None try: with driver_tools.get_session(database="system") as session: result = session.run( "SHOW DATABASES YIELD name " "WHERE name STARTS WITH 'cc.institutes.' " "AND NOT name ENDS WITH '.curriculum' " "RETURN name" ) dbs = [r["name"] for r in result] for db in dbs: try: with driver_tools.get_session(database=db) as session: rec = session.run( "MATCH (t:Teacher) WHERE t.worker_email = $email " "RETURN t.uuid_string AS uuid LIMIT 1", email=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 # ─── Node queries ─────────────────────────────────────────────────────────────── def _query_user_node(teacher_db: str) -> Optional[Dict]: try: with driver_tools.get_session(database=teacher_db) as session: rec = session.run("MATCH (u:User) RETURN u LIMIT 1").single() if not rec: return None u = rec["u"] return { "neo4j_node_id": u["uuid_string"], "label": u.get("user_name") or u.get("cc_username") or "My Workspace", "node_type": "User", "neo4j_db_name": teacher_db, "is_section": False, "has_children": True, } except Exception as e: logger.warning(f"Could not query User node in {teacher_db}: {e}") return None def _query_calendar_months(year: str) -> List[Dict]: try: with driver_tools.get_session(database="classroomcopilot") as session: result = session.run( "MATCH (y:CalendarYear {year: $year})-[:YEAR_INCLUDES_MONTH]->(m:CalendarMonth) " "RETURN m ORDER BY toInteger(m.month)", year=year, ) return [ { "neo4j_node_id": r["m"]["uuid_string"], "label": r["m"]["month_name"], "node_type": "CalendarMonth", "neo4j_db_name": "classroomcopilot", "is_section": False, "has_children": True, } for r in result ] except Exception as e: logger.error(f"Error querying calendar months for {year}: {e}") return [] def _query_month_days(month_uuid: str) -> List[Dict]: try: with driver_tools.get_session(database="classroomcopilot") as session: result = session.run( "MATCH (m:CalendarMonth {uuid_string: $mid})-[:MONTH_INCLUDES_DAY]->(d:CalendarDay) " "RETURN d ORDER BY d.date", mid=month_uuid, ) return [ { "neo4j_node_id": r["d"]["uuid_string"], "label": f"{r['d']['day_of_week'][:3]} {r['d']['iso_day']}", "node_type": "CalendarDay", "neo4j_db_name": "classroomcopilot", "is_section": False, "has_children": False, "neo4j_props": { "date": str(r["d"].get("date", "")), "day_of_week": r["d"].get("day_of_week", ""), "iso_day": r["d"].get("iso_day", ""), }, } for r in result ] except Exception as e: logger.error(f"Error querying days for month {month_uuid}: {e}") return [] def _query_teacher_classes(institute_db: str, teacher_uuid: str) -> List[Dict]: try: with driver_tools.get_session(database=institute_db) as session: result = session.run( "MATCH (t:Teacher {uuid_string: $uuid})-[:TEACHER_HAS_CLASS]->(c:SubjectClass) " "RETURN c ORDER BY c.name", uuid=teacher_uuid, ) return [ { "neo4j_node_id": r["c"]["uuid_string"], "label": r["c"].get("name") or "Class", "node_type": "SubjectClass", "neo4j_db_name": institute_db, "is_section": False, "has_children": True, } for r in result ] except Exception as e: logger.warning(f"Could not query classes for teacher {teacher_uuid}: {e}") return [] # ─── Section builders ─────────────────────────────────────────────────────────── def _section(section_id: str, label: str, db: str, status: str, has_children: bool = False, children: Optional[List] = None) -> Dict: return { "neo4j_node_id": f"section_{section_id}", "label": label, "node_type": "Section", "section_id": section_id, "neo4j_db_name": db, "is_section": True, "has_children": has_children, "status": status, **({"children": children} if children is not None else {}), } def _build_calendar_section() -> Dict: try: with driver_tools.get_session(database="classroomcopilot") as session: rows = session.run( "MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)" ).data() if not rows: return _section("calendar", "Calendar", "classroomcopilot", "empty") year_nodes = [ { "neo4j_node_id": r["y"]["uuid_string"], "label": r["y"].get("year") or r["y"]["uuid_string"], "node_type": "CalendarYear", "neo4j_db_name": "classroomcopilot", "is_section": False, "has_children": True, } for r in rows ] return _section( "calendar", "Calendar", "classroomcopilot", "populated", has_children=True, children=year_nodes, ) except Exception as e: logger.warning(f"Calendar section build failed: {e}") return _section("calendar", "Calendar", "classroomcopilot", "empty") def _build_timetable_section(institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict: if not institute_db or not teacher_uuid: return _section("timetable", "My Timetable", "", "no_school") try: with driver_tools.get_session(database=institute_db) as session: rec = session.run( "MATCH (t:Teacher {uuid_string: $uuid})-[:HAS_TIMETABLE]->(tt) " "RETURN tt LIMIT 1", uuid=teacher_uuid, ).single() if rec: tt = rec["tt"] return { **_section("timetable", "My Timetable", institute_db, "populated", has_children=True), "neo4j_node_id": tt["uuid_string"], "node_type": "TeacherTimetable", "is_section": True, } except Exception as e: logger.warning(f"Timetable query failed: {e}") return _section("timetable", "My Timetable", institute_db, "empty") def _build_classes_section(institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict: if not institute_db or not teacher_uuid: return _section("classes", "My Classes", "", "no_school") classes = _query_teacher_classes(institute_db, teacher_uuid) if classes: return _section("classes", "My Classes", institute_db, "populated", has_children=True, children=classes) return _section("classes", "My Classes", institute_db, "empty") def _build_curriculum_section(institute_db: Optional[str]) -> Dict: if not institute_db: return _section("curriculum", "Curriculum", "", "no_school") # Check for curriculum DB curriculum_db = f"{institute_db}.curriculum" try: with driver_tools.get_session(database="system") as session: rec = session.run( "SHOW DATABASES YIELD name WHERE name = $db RETURN name", db=curriculum_db, ).single() if not rec: return _section("curriculum", "Curriculum", institute_db, "empty") with driver_tools.get_session(database=curriculum_db) as session: rec = session.run("MATCH (n) RETURN count(n) AS cnt").single() cnt = rec["cnt"] if rec else 0 if cnt > 0: return _section("curriculum", "Curriculum", curriculum_db, "populated", has_children=True) except Exception as e: logger.warning(f"Curriculum check failed: {e}") return _section("curriculum", "Curriculum", institute_db, "empty") def _build_journal_section(teacher_db: str) -> Dict: try: with driver_tools.get_session(database=teacher_db) as session: rec = session.run("MATCH (j:Journal) RETURN j LIMIT 1").single() if rec: j = rec["j"] return { **_section("journal", "Journal", teacher_db, "populated", has_children=True), "neo4j_node_id": j["uuid_string"], "node_type": "Journal", "is_section": True, } except Exception as e: logger.warning(f"Journal query failed: {e}") return _section("journal", "Journal", teacher_db, "not_initialized") def _build_planner_section(teacher_db: str) -> Dict: try: with driver_tools.get_session(database=teacher_db) as session: rec = session.run("MATCH (p:Planner) RETURN p LIMIT 1").single() if rec: p = rec["p"] return { **_section("planner", "Planner", teacher_db, "populated", has_children=True), "neo4j_node_id": p["uuid_string"], "node_type": "Planner", "is_section": True, } except Exception as e: logger.warning(f"Planner query failed: {e}") return _section("planner", "Planner", teacher_db, "not_initialized") def _build_school_section(institute_db: str) -> Dict: try: with driver_tools.get_session(database=institute_db) as session: rec = session.run("MATCH (s:School) RETURN s LIMIT 1").single() if not rec: return _section("school", "My School", institute_db, "empty") s = rec["s"] name = s.get("name") or "My School" # Academic years under this school ay_rows = session.run( "MATCH (ay:AcademicYear) RETURN ay ORDER BY ay.year" ).data() children = [ { "neo4j_node_id": row["ay"]["uuid_string"], "label": row["ay"].get("year") or "Academic Year", "node_type": "AcademicYear", "neo4j_db_name": institute_db, "is_section": False, "has_children": True, } for row in ay_rows ] return { **_section("school", name, institute_db, "populated", has_children=len(children) > 0, children=children if children else None), "neo4j_node_id": s["uuid_string"], "node_type": "School", "is_section": True, } except Exception as e: logger.warning(f"School query failed: {e}") return _section("school", "My School", institute_db, "empty") # ─── Lazy children for expanded nodes ────────────────────────────────────────── def _get_children_for_node( neo4j_node_id: str, neo4j_db_name: str, node_type: str, section_id: str = "", ) -> List[Dict]: # Calendar if node_type == "CalendarYear": return _query_calendar_months(neo4j_node_id) if node_type == "CalendarMonth": return _query_month_days(neo4j_node_id) # Section containers that need lazy loading if node_type == "Section": if section_id == "timetable" and neo4j_db_name: # Children of timetable section = classes try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (tt)-[:TIMETABLE_HAS_CLASS]->(c:SubjectClass) " "WHERE tt.uuid_string = $id " "RETURN c ORDER BY c.name", id=neo4j_node_id, ) return [ { "neo4j_node_id": r["c"]["uuid_string"], "label": r["c"].get("name") or "Class", "node_type": "SubjectClass", "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": True, } for r in result ] except Exception as e: logger.warning(f"Timetable children query failed: {e}") return [] if section_id == "curriculum" and neo4j_db_name: try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (s:KeyStageSyllabus) RETURN s ORDER BY s.key_stage" ) rows = list(result) if not rows: result = session.run( "MATCH (s:YearGroupSyllabus) RETURN s ORDER BY s.year_group" ) rows = list(result) return [ { "neo4j_node_id": r[list(r.keys())[0]]["uuid_string"], "label": r[list(r.keys())[0]].get("name") or r[list(r.keys())[0]].get("key_stage") or "Syllabus", "node_type": list(r.keys())[0], "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": True, } for r in rows ] except Exception as e: logger.warning(f"Curriculum children query failed: {e}") return [] if section_id == "school" and neo4j_db_name: try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (s:School {uuid_string: $id})-[:HAS_DEPARTMENT]->(d) " "RETURN d ORDER BY d.name", id=neo4j_node_id, ) return [ { "neo4j_node_id": r["d"]["uuid_string"], "label": r["d"].get("name") or "Department", "node_type": "Department", "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": True, } for r in result ] except Exception as e: logger.warning(f"School children query failed: {e}") return [] # AcademicYear → terms if node_type == "AcademicYear" and neo4j_db_name: try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (ay:AcademicYear {uuid_string: $id})" "-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) " "RETURN t ORDER BY toInteger(t.term_number)", id=neo4j_node_id, ) return [ { "neo4j_node_id": r["t"]["uuid_string"], "label": r["t"]["term_name"], "node_type": "AcademicTerm", "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": True, } for r in result ] except Exception as e: logger.warning(f"AcademicYear children failed: {e}") return [] # AcademicWeek → days if node_type == "AcademicWeek" and neo4j_db_name: try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (w:AcademicWeek {uuid_string: })" "-[:ACADEMIC_WEEK_HAS_ACADEMIC_DAY]->(d:AcademicDay) " "RETURN d ORDER BY d.date", id=neo4j_node_id, ) days = [ { "neo4j_node_id": r["d"]["uuid_string"], "label": r["d"].get("date", ""), "node_type": "AcademicDay", "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": False, } for r in result ] return days except Exception as e: logger.warning(f"AcademicWeek children failed: {e}") return [] # AcademicTerm → weeks if node_type == "AcademicTerm" and neo4j_db_name: try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (t:AcademicTerm {uuid_string: $id})" "-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) " "RETURN w ORDER BY toInteger(w.academic_week_number)", id=neo4j_node_id, ) return [ { "neo4j_node_id": r["w"]["uuid_string"], "label": f"Week {r['w']['academic_week_number']}", "node_type": "AcademicWeek", "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": True, } for r in result ] except Exception as e: logger.warning(f"AcademicTerm children failed: {e}") return [] # SubjectClass → lessons if node_type == "SubjectClass" and neo4j_db_name: try: with driver_tools.get_session(database=neo4j_db_name) as session: result = session.run( "MATCH (c:SubjectClass {uuid_string: $id})-[:CLASS_HAS_LESSON]->(l) " "RETURN l ORDER BY l.date, l.start_time LIMIT 50", id=neo4j_node_id, ) return [ { "neo4j_node_id": r["l"]["uuid_string"], "label": r["l"].get("title") or r["l"].get("period_code") or "Lesson", "node_type": "TimetableLesson", "neo4j_db_name": neo4j_db_name, "is_section": False, "has_children": False, } for r in result ] except Exception as e: logger.warning(f"Class lessons query failed: {e}") return [] # ─── Endpoints ────────────────────────────────────────────────────────────────── @router.get("/tree") async def get_teacher_graph_tree( credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: user_id = credentials.get("sub", "") user_email = credentials.get("email", "") if not user_id: raise HTTPException(status_code=403, detail="Could not extract user_id from token") teacher_db = _user_to_teacher_db(user_id) user_node = _query_user_node(teacher_db) or { "neo4j_node_id": user_id, "label": "My Workspace", "node_type": "User", "neo4j_db_name": teacher_db, "is_section": False, "has_children": True, } _, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email) sections = [ _build_calendar_section(), _build_timetable_section(institute_db, teacher_node_uuid), _build_classes_section(institute_db, teacher_node_uuid), _build_curriculum_section(institute_db), _build_journal_section(teacher_db), _build_planner_section(teacher_db), ] if institute_db: sections.append(_build_school_section(institute_db)) return { "status": "success", "tree": {**user_node, "children": sections}, "meta": { "institute_db": institute_db, "teacher_linked": institute_db is not None, }, } @router.get("/node/children") async def get_node_children( neo4j_node_id: str, neo4j_db_name: str, node_type: str, section_id: str = "", credentials: dict = Depends(SupabaseBearer()), ) -> Dict[str, Any]: children = _get_children_for_node(neo4j_node_id, neo4j_db_name, node_type, section_id) return {"status": "success", "children": children} @router.get("/calendar/academic") async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: user_id = credentials.get("sub", "") user_email = credentials.get("email", "") _, institute_db, _ = _resolve_institute(user_id, user_email) if not institute_db: return {"status": "no_school", "terms": []} try: with driver_tools.get_session(database=institute_db) as s: if not s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single(): return {"status": "empty", "terms": [], "institute_db": institute_db} terms = [] for tr in s.run( "MATCH (ay:AcademicYear)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) " "RETURN t ORDER BY toInteger(t.term_number)" ): t = tr["t"] with driver_tools.get_session(database=institute_db) as s2: weeks = [{ "neo4j_node_id": w["w"]["uuid_string"], "label": f"Week {w['w']['academic_week_number']}", "node_type": "AcademicWeek", "neo4j_db_name": institute_db, "is_section": False, "has_children": True, } for w in s2.run( "MATCH (t:AcademicTerm {uuid_string: $tid})-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) " "RETURN w ORDER BY toInteger(w.academic_week_number)", tid=t["uuid_string"] )] terms.append({ "neo4j_node_id": t["uuid_string"], "label": t["term_name"], "node_type": "AcademicTerm", "neo4j_db_name": institute_db, "is_section": False, "has_children": len(weeks) > 0, "children": weeks, }) return {"status": "populated", "terms": terms, "institute_db": institute_db} except Exception as e: logger.error(f"Academic calendar query failed: {e}") return {"status": "error", "terms": []}