diff --git a/modules/database/schemas/nodes/users.py b/modules/database/schemas/nodes/users.py index f58b5f0..26020b4 100644 --- a/modules/database/schemas/nodes/users.py +++ b/modules/database/schemas/nodes/users.py @@ -1,5 +1,13 @@ from typing import ClassVar -from .base_nodes import UserBaseNode +from .base_nodes import UserBaseNode, CCBaseNode class UserNode(UserBaseNode): __primarylabel__: ClassVar[str] = 'User' + +class JournalNode(CCBaseNode): + __primarylabel__: ClassVar[str] = 'Journal' + user_id: str + +class PlannerNode(CCBaseNode): + __primarylabel__: ClassVar[str] = 'Planner' + user_id: str diff --git a/routers/database/tools/graph_tree_router.py b/routers/database/tools/graph_tree_router.py new file mode 100644 index 0000000..02524a6 --- /dev/null +++ b/routers/database/tools/graph_tree_router.py @@ -0,0 +1,588 @@ +import os +from datetime import datetime +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 + +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 _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: + current_year = str(datetime.now().year) + months = _query_calendar_months(current_year) + calendar_year_node = { + "neo4j_node_id": current_year, + "label": current_year, + "node_type": "CalendarYear", + "neo4j_db_name": "classroomcopilot", + "is_section": False, + "has_children": True, + "children": months, + } + return _section( + "calendar", "Calendar", "classroomcopilot", "populated", + has_children=True, children=[calendar_year_node], + ) + + +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 [] + + # 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": False, + } + 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 = _find_teacher_institute(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_email = credentials.get("email", "") + institute_db, _ = _find_teacher_institute(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": False, + } 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": []} diff --git a/routers/database/tools/school_router.py b/routers/database/tools/school_router.py new file mode 100644 index 0000000..8cf92b1 --- /dev/null +++ b/routers/database/tools/school_router.py @@ -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)} diff --git a/routers/database/tools/timetable_builder_router.py b/routers/database/tools/timetable_builder_router.py new file mode 100644 index 0000000..fe61786 --- /dev/null +++ b/routers/database/tools/timetable_builder_router.py @@ -0,0 +1,367 @@ +""" +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)} diff --git a/routers/database/tools/user_init_router.py b/routers/database/tools/user_init_router.py new file mode 100644 index 0000000..94b0698 --- /dev/null +++ b/routers/database/tools/user_init_router.py @@ -0,0 +1,81 @@ +import os +from typing import Dict, Any +from fastapi import APIRouter, Depends +from modules.logger_tool import initialise_logger +from modules.auth.supabase_bearer import SupabaseBearer +from modules.database.services.provisioning_service import ProvisioningService +import modules.database.tools.neo4j_driver_tools as driver_tools +import modules.database.schemas.nodes.users as user_nodes +import modules.database.tools.neontology_tools as neon + +logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) +router = APIRouter() + + +def _teacher_db(user_id: str) -> str: + return f"cc.users.teacher.{user_id.replace('-', '')}" + + +def _ensure_journal_planner(user_id: str, teacher_db: str) -> None: + neon.init_neontology_connection() + try: + with driver_tools.get_session(database=teacher_db) as session: + has_journal = session.run("MATCH (j:Journal) RETURN count(j) AS n").single()["n"] > 0 + has_planner = session.run("MATCH (p:Planner) RETURN count(p) AS n").single()["n"] > 0 + + if not has_journal: + journal = user_nodes.JournalNode( + uuid_string=f"{user_id}_journal", + node_storage_path=f"users/{user_id}/nodes/journal", + user_id=user_id, + ) + neon.create_or_merge_neontology_node(journal, database=teacher_db, operation='merge') + logger.info(f"Created Journal node for {user_id}") + + if not has_planner: + planner = user_nodes.PlannerNode( + uuid_string=f"{user_id}_planner", + node_storage_path=f"users/{user_id}/nodes/planner", + user_id=user_id, + ) + neon.create_or_merge_neontology_node(planner, database=teacher_db, operation='merge') + logger.info(f"Created Planner node for {user_id}") + finally: + neon.close_neontology_connection() + + +@router.post("/init") +async def init_user(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]: + user_id = credentials.get("sub", "") + if not user_id: + return {"status": "error", "message": "No user ID in token"} + + db = _teacher_db(user_id) + + # Fast path: check if already fully provisioned + try: + with driver_tools.get_session(database=db) as session: + r = session.run( + "MATCH (u:User) " + "OPTIONAL MATCH (j:Journal) " + "OPTIONAL MATCH (p:Planner) " + "RETURN count(u) AS u, count(j) AS j, count(p) AS p" + ).single() + if r and r["u"] > 0 and r["j"] > 0 and r["p"] > 0: + logger.debug(f"User {user_id} already initialized — fast path") + return {"status": "ok", "initialized": True, "teacher_db": db} + except Exception: + pass # DB doesn't exist yet — fall through to full provisioning + + # Full provisioning + try: + logger.info(f"Provisioning user {user_id}...") + service = ProvisioningService() + result = service.ensure_user(user_id) + user_db = result.get("user_db_name") or db + _ensure_journal_planner(user_id, user_db) + logger.info(f"User {user_id} provisioned successfully: {user_db}") + return {"status": "ok", "initialized": True, "teacher_db": user_db} + except Exception as e: + logger.error(f"User init failed for {user_id}: {e}") + return {"status": "error", "message": str(e)} diff --git a/run/routers.py b/run/routers.py index df6a442..af3062f 100644 --- a/run/routers.py +++ b/run/routers.py @@ -9,6 +9,10 @@ from routers.msgraph import router_onenote from routers.dev.tests import timetable_test from routers.database.init import entity_init, calendar, timetables, curriculum, get_data, schools from routers.database.tools import get_nodes, get_nodes_and_edges, tldraw_filesystem, tldraw_supabase_storage, get_events, calendar_structure_router, default_nodes_router, worker_structure_router +from routers.database.tools.graph_tree_router import router as graph_tree_router +from routers.database.tools.user_init_router import router as user_init_router +from routers.database.tools.timetable_builder_router import router as timetable_builder_router +from routers.database.tools.school_router import router as school_router from routers.database.files import cabinets as cabinets_router from routers.database.files import files as files_router from routers.simple_upload import router as simple_upload_router @@ -53,6 +57,12 @@ def register_routes(app: FastAPI): # Navigation Routes app.include_router(calendar_structure_router.router, prefix="/database/calendar-structure", tags=["Calendar"]) app.include_router(worker_structure_router.router, prefix="/database/worker-structure", tags=["Worker"]) + + # Graph navigation + app.include_router(graph_tree_router, prefix="/graph", tags=["Graph Navigation"]) + app.include_router(user_init_router, prefix="/user", tags=["User"]) + app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"]) + app.include_router(school_router, prefix="/school", tags=["School"]) app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"]) # Database Filesystem Routes