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 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
|
||||||
|
|||||||
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.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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user