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 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-26 01:24:44 +01:00
parent 84f7fa9de1
commit fe3d7a12c8
6 changed files with 1249 additions and 1 deletions

View File

@ -1,5 +1,13 @@
from typing import ClassVar from typing import ClassVar
from .base_nodes import UserBaseNode from .base_nodes import UserBaseNode, CCBaseNode
class UserNode(UserBaseNode): class UserNode(UserBaseNode):
__primarylabel__: ClassVar[str] = 'User' __primarylabel__: ClassVar[str] = 'User'
class JournalNode(CCBaseNode):
__primarylabel__: ClassVar[str] = 'Journal'
user_id: str
class PlannerNode(CCBaseNode):
__primarylabel__: ClassVar[str] = 'Planner'
user_id: str

View File

@ -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": []}

View File

@ -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)}

View File

@ -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)}

View File

@ -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)}

View File

@ -9,6 +9,10 @@ from routers.msgraph import router_onenote
from routers.dev.tests import timetable_test from routers.dev.tests import timetable_test
from routers.database.init import entity_init, calendar, timetables, curriculum, get_data, schools 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 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 cabinets as cabinets_router
from routers.database.files import files as files_router from routers.database.files import files as files_router
from routers.simple_upload import router as simple_upload_router from routers.simple_upload import router as simple_upload_router
@ -53,6 +57,12 @@ def register_routes(app: FastAPI):
# Navigation Routes # Navigation Routes
app.include_router(calendar_structure_router.router, prefix="/database/calendar-structure", tags=["Calendar"]) 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"]) 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"]) app.include_router(default_nodes_router.router, prefix="/database/tools", tags=["Navigation"])
# Database Filesystem Routes # Database Filesystem Routes