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:
parent
84f7fa9de1
commit
fe3d7a12c8
@ -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
|
||||
|
||||
588
routers/database/tools/graph_tree_router.py
Normal file
588
routers/database/tools/graph_tree_router.py
Normal 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": []}
|
||||
194
routers/database/tools/school_router.py
Normal file
194
routers/database/tools/school_router.py
Normal 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)}
|
||||
367
routers/database/tools/timetable_builder_router.py
Normal file
367
routers/database/tools/timetable_builder_router.py
Normal 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)}
|
||||
81
routers/database/tools/user_init_router.py
Normal file
81
routers/database/tools/user_init_router.py
Normal 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)}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user