903 lines
37 KiB
Python
903 lines
37 KiB
Python
import os
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from modules.logger_tool import initialise_logger
|
|
from modules.auth.supabase_bearer import SupabaseBearer
|
|
import modules.database.tools.neo4j_driver_tools as driver_tools
|
|
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
|
|
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
|
router = APIRouter()
|
|
|
|
|
|
# ─── DB helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _user_to_teacher_db(user_id: str) -> str:
|
|
return f"cc.users.teacher.{user_id.replace('-', '')}"
|
|
|
|
|
|
def _sb() -> SupabaseServiceRoleClient:
|
|
return SupabaseServiceRoleClient()
|
|
|
|
|
|
def _find_teacher_uuid(db: str, user_email: str) -> Optional[str]:
|
|
"""Query teacher UUID from a known Neo4j institute DB."""
|
|
try:
|
|
with driver_tools.get_session(database=db) as session:
|
|
rec = session.run(
|
|
'MATCH (t:Teacher) WHERE t.worker_email = $email '
|
|
'RETURN t.uuid_string AS uuid LIMIT 1',
|
|
email=user_email,
|
|
).single()
|
|
if rec:
|
|
return rec['uuid']
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _resolve_institute(
|
|
user_id: str, user_email: str
|
|
) -> tuple:
|
|
"""Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid).
|
|
Supabase-first lookup with Neo4j email-scan fallback."""
|
|
try:
|
|
sb = _sb()
|
|
p = sb.supabase.table('profiles').select('school_id').eq('id', user_id).single().execute()
|
|
school_id = (p.data or {}).get('school_id')
|
|
if school_id:
|
|
i = sb.supabase.table('institutes').select('id,neo4j_uuid_string').eq('id', str(school_id)).single().execute()
|
|
inst = i.data or {}
|
|
neo4j_uuid = inst.get('neo4j_uuid_string')
|
|
if neo4j_uuid:
|
|
db = f'cc.institutes.{neo4j_uuid}'
|
|
teacher_uuid = _find_teacher_uuid(db, user_email)
|
|
return str(school_id), db, teacher_uuid
|
|
except Exception as e:
|
|
logger.warning(f'Supabase-first institute resolve failed: {e}')
|
|
# Fallback: scan Neo4j
|
|
db, teacher_uuid = _find_teacher_institute(user_email)
|
|
return None, db, teacher_uuid
|
|
|
|
|
|
def _allowed_neo4j_dbs(user_id: str, user_email: str) -> set[str]:
|
|
"""Return Neo4j databases this user may request via lazy graph APIs."""
|
|
allowed = {f"cc.users.teacher.{user_id.replace('-', '')}"} if user_id else set()
|
|
if user_id or user_email:
|
|
_, institute_db, _ = _resolve_institute(user_id, user_email)
|
|
if institute_db:
|
|
allowed.add(institute_db)
|
|
allowed.add(f"{institute_db}.curriculum")
|
|
return allowed
|
|
|
|
|
|
def _require_allowed_neo4j_db(neo4j_db_name: str, node_type: str, section_id: str, user_id: str, user_email: str) -> None:
|
|
"""Reject arbitrary DB traversal from /graph/node/children query params."""
|
|
if not neo4j_db_name:
|
|
raise HTTPException(status_code=400, detail="neo4j_db_name is required")
|
|
|
|
if neo4j_db_name == "classroomcopilot":
|
|
if node_type.startswith("Calendar") or section_id == "calendar":
|
|
return
|
|
raise HTTPException(status_code=403, detail="Requested graph database is not allowed for this node")
|
|
|
|
if neo4j_db_name in _allowed_neo4j_dbs(user_id, user_email):
|
|
return
|
|
|
|
raise HTTPException(status_code=403, detail="Requested graph database is outside the authenticated user's scope")
|
|
|
|
|
|
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(
|
|
user_id: str, institute_id: str, institute_db: str, section_id: str = ""
|
|
) -> List[Dict]:
|
|
"""Query classes for a teacher or student from Supabase (source of truth)."""
|
|
try:
|
|
sb = _sb()
|
|
teacher_rows = (
|
|
sb.supabase.table("class_teachers")
|
|
.select("class_id, is_primary")
|
|
.eq("teacher_id", user_id)
|
|
.execute()
|
|
.data or []
|
|
)
|
|
teacher_class_ids = {r["class_id"] for r in teacher_rows}
|
|
student_rows = (
|
|
sb.supabase.table("class_students")
|
|
.select("class_id")
|
|
.eq("student_id", user_id)
|
|
.eq("status", "active")
|
|
.execute()
|
|
.data or []
|
|
)
|
|
student_class_ids = {r["class_id"] for r in student_rows}
|
|
all_ids = list(teacher_class_ids | student_class_ids)
|
|
if not all_ids:
|
|
return []
|
|
classes = (
|
|
sb.supabase.table("classes")
|
|
.select("id, name, class_code, subject")
|
|
.in_("id", all_ids)
|
|
.eq("institute_id", institute_id)
|
|
.eq("is_active", True)
|
|
.order("name")
|
|
.execute()
|
|
.data or []
|
|
)
|
|
result = []
|
|
for c in classes:
|
|
role = "teacher" if c["id"] in teacher_class_ids else "student"
|
|
label = c.get("class_code") or c.get("name") or "Class"
|
|
node: Dict = {
|
|
"neo4j_node_id": c["id"],
|
|
"label": label,
|
|
"node_type": "SubjectClass",
|
|
"neo4j_db_name": institute_db,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
"neo4j_props": {
|
|
"role": role,
|
|
"subject": c.get("subject") or "",
|
|
"name": c.get("name") or "",
|
|
"class_code": c.get("class_code") or "",
|
|
},
|
|
}
|
|
if section_id:
|
|
node["section_id"] = section_id
|
|
result.append(node)
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"Could not query classes for user {user_id}: {e}")
|
|
return []
|
|
|
|
|
|
# ─── Section builders ───────────────────────────────────────────────────────────
|
|
|
|
def _section(section_id: str, label: str, db: str, status: str,
|
|
has_children: bool = False, children: Optional[List] = None) -> Dict:
|
|
return {
|
|
"neo4j_node_id": f"section_{section_id}",
|
|
"label": label,
|
|
"node_type": "Section",
|
|
"section_id": section_id,
|
|
"neo4j_db_name": db,
|
|
"is_section": True,
|
|
"has_children": has_children,
|
|
"status": status,
|
|
**({"children": children} if children is not None else {}),
|
|
}
|
|
|
|
|
|
def _build_calendar_section() -> Dict:
|
|
try:
|
|
with driver_tools.get_session(database="classroomcopilot") as session:
|
|
rows = session.run(
|
|
"MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
|
|
).data()
|
|
if not rows:
|
|
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
|
year_nodes = [
|
|
{
|
|
"neo4j_node_id": r["y"]["uuid_string"],
|
|
"label": r["y"].get("year") or r["y"]["uuid_string"],
|
|
"node_type": "CalendarYear",
|
|
"neo4j_db_name": "classroomcopilot",
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in rows
|
|
]
|
|
return _section(
|
|
"calendar", "Calendar", "classroomcopilot", "populated",
|
|
has_children=True, children=year_nodes,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Calendar section build failed: {e}")
|
|
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
|
|
|
|
|
def _build_timetable_section(user_id: str, institute_id: Optional[str], institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict:
|
|
if not institute_db or not teacher_uuid or not institute_id:
|
|
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"]
|
|
tt_uuid = tt["uuid_string"]
|
|
# Load classes from Supabase (source of truth)
|
|
classes = _query_teacher_classes(user_id, institute_id, institute_db, section_id="timetable")
|
|
return {
|
|
**_section("timetable", "My Timetable", institute_db, "populated",
|
|
has_children=True, children=classes if classes else None),
|
|
"neo4j_node_id": tt_uuid,
|
|
"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(user_id: str, institute_id: Optional[str], institute_db: Optional[str]) -> Dict:
|
|
if not institute_db or not institute_id:
|
|
return _section("classes", "My Classes", "", "no_school")
|
|
|
|
classes = _query_teacher_classes(user_id, institute_id, institute_db, section_id="classes")
|
|
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 = "",
|
|
user_email: 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)
|
|
|
|
# TeacherTimetable lazy-load (fallback if not pre-loaded, or for By-Term view)
|
|
if node_type == "TeacherTimetable" and neo4j_db_name:
|
|
if section_id in ("", "timetable"):
|
|
# Classes are pre-loaded in _build_timetable_section via Supabase.
|
|
# Lazy expansion here is not needed in normal flow.
|
|
return []
|
|
if section_id == "timetable-term":
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (tt:TeacherTimetable {uuid_string: $id}) "
|
|
"-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay:AcademicYear) "
|
|
"-[: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"].get("term_name") or "Term {}".format(r["t"].get("term_number", "")),
|
|
"node_type": "AcademicTerm",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"section_id": "timetable-term",
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"TeacherTimetable term children failed: {e}")
|
|
return []
|
|
|
|
# SubjectClass — expand to taught lessons (timetable context) or members (classes context)
|
|
if node_type == "SubjectClass":
|
|
class_id = neo4j_node_id # Supabase class UUID
|
|
try:
|
|
sb = _sb()
|
|
if section_id == "timetable" and user_email:
|
|
# Resolve teacher profile_id from email
|
|
prof = sb.supabase.table("profiles").select("id").eq("email", user_email).single().execute()
|
|
teacher_id = (prof.data or {}).get("id")
|
|
if teacher_id:
|
|
lessons = (
|
|
sb.supabase.table("taught_lessons")
|
|
.select("id, date, period_code, class_name, subject")
|
|
.eq("class_id", class_id)
|
|
.eq("teacher_profile_id", teacher_id)
|
|
.order("date")
|
|
.order("period_code")
|
|
.execute()
|
|
.data or []
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": tl["id"],
|
|
"label": "{} {}".format(
|
|
tl.get("period_code") or "",
|
|
tl.get("date") or ""
|
|
).strip(),
|
|
"node_type": "TaughtLesson",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": False,
|
|
"section_id": "timetable",
|
|
}
|
|
for tl in lessons
|
|
]
|
|
return []
|
|
if section_id == "classes":
|
|
# Class members: students enrolled in this class
|
|
members = (
|
|
sb.supabase.table("class_students")
|
|
.select("student_id, status, enrolled_at")
|
|
.eq("class_id", class_id)
|
|
.order("enrolled_at")
|
|
.execute()
|
|
.data or []
|
|
)
|
|
if not members:
|
|
return []
|
|
student_ids = [m["student_id"] for m in members]
|
|
status_map = {m["student_id"]: m["status"] for m in members}
|
|
profiles = (
|
|
sb.supabase.table("profiles")
|
|
.select("id, email, first_name, last_name, user_type")
|
|
.in_("id", student_ids)
|
|
.order("last_name")
|
|
.execute()
|
|
.data or []
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": p["id"],
|
|
"label": "{} {} ({})".format(
|
|
p.get("first_name") or "",
|
|
p.get("last_name") or "",
|
|
status_map.get(p["id"], ""),
|
|
).strip(),
|
|
"node_type": "Student",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": False,
|
|
"section_id": "classes",
|
|
}
|
|
for p in profiles
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"SubjectClass children failed: {e}")
|
|
return []
|
|
|
|
# Section containers that need lazy loading
|
|
if node_type == "Section":
|
|
if section_id == "timetable" and neo4j_db_name:
|
|
# Children of timetable section = classes
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (tt)-[:TIMETABLE_HAS_CLASS]->(c:SubjectClass) "
|
|
"WHERE tt.uuid_string = $id "
|
|
"RETURN c ORDER BY c.name",
|
|
id=neo4j_node_id,
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r["c"]["uuid_string"],
|
|
"label": r["c"].get("name") or "Class",
|
|
"node_type": "SubjectClass",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Timetable children query failed: {e}")
|
|
return []
|
|
|
|
if section_id == "curriculum" and neo4j_db_name:
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (s:KeyStageSyllabus) RETURN s ORDER BY s.key_stage"
|
|
)
|
|
rows = list(result)
|
|
if not rows:
|
|
result = session.run(
|
|
"MATCH (s:YearGroupSyllabus) RETURN s ORDER BY s.year_group"
|
|
)
|
|
rows = list(result)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r[list(r.keys())[0]]["uuid_string"],
|
|
"label": r[list(r.keys())[0]].get("name") or r[list(r.keys())[0]].get("key_stage") or "Syllabus",
|
|
"node_type": list(r.keys())[0],
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in rows
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Curriculum children query failed: {e}")
|
|
return []
|
|
|
|
if section_id == "school" and neo4j_db_name:
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (s:School {uuid_string: $id})-[:HAS_DEPARTMENT]->(d) "
|
|
"RETURN d ORDER BY d.name",
|
|
id=neo4j_node_id,
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r["d"]["uuid_string"],
|
|
"label": r["d"].get("name") or "Department",
|
|
"node_type": "Department",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"School children query failed: {e}")
|
|
return []
|
|
|
|
# AcademicYear → terms
|
|
if node_type == "AcademicYear" and neo4j_db_name:
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (ay:AcademicYear {uuid_string: $id})"
|
|
"-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
|
|
"RETURN t ORDER BY toInteger(t.term_number)",
|
|
id=neo4j_node_id,
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r["t"]["uuid_string"],
|
|
"label": r["t"]["term_name"],
|
|
"node_type": "AcademicTerm",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"AcademicYear children failed: {e}")
|
|
return []
|
|
|
|
# AcademicWeek → days (or TaughtLessons in timetable-term context)
|
|
if node_type == "AcademicWeek" and neo4j_db_name:
|
|
if section_id == "timetable-term" and user_email:
|
|
# Supabase: get week date range from Neo4j, then query taught_lessons
|
|
try:
|
|
week_start = None
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
rec = session.run(
|
|
"MATCH (w:AcademicWeek {uuid_string: $id}) "
|
|
"RETURN w.start_date AS start_date",
|
|
id=neo4j_node_id,
|
|
).single()
|
|
if rec:
|
|
week_start = rec["start_date"]
|
|
if week_start:
|
|
from datetime import datetime, timedelta
|
|
start_dt = datetime.strptime(str(week_start)[:10], "%Y-%m-%d")
|
|
end_dt = start_dt + timedelta(days=6)
|
|
sb = _sb()
|
|
prof = sb.supabase.table("profiles").select("id").eq("email", user_email).single().execute()
|
|
teacher_id = (prof.data or {}).get("id")
|
|
if teacher_id:
|
|
lessons = (
|
|
sb.supabase.table("taught_lessons")
|
|
.select("id, date, period_code, class_name, subject")
|
|
.eq("teacher_profile_id", teacher_id)
|
|
.gte("date", start_dt.strftime("%Y-%m-%d"))
|
|
.lte("date", end_dt.strftime("%Y-%m-%d"))
|
|
.order("date")
|
|
.order("period_code")
|
|
.execute()
|
|
.data or []
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": tl["id"],
|
|
"label": "{} — {}".format(
|
|
tl.get("period_code") or "",
|
|
tl.get("class_name") or tl.get("subject") or "Lesson"
|
|
),
|
|
"node_type": "TaughtLesson",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": False,
|
|
}
|
|
for tl in lessons
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"AcademicWeek timetable-term Supabase lessons failed: {e}")
|
|
return []
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (w:AcademicWeek {uuid_string: $id}) "
|
|
"-[:ACADEMIC_WEEK_HAS_ACADEMIC_DAY]->(d:AcademicDay) "
|
|
"RETURN d ORDER BY d.date",
|
|
id=neo4j_node_id,
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r["d"]["uuid_string"],
|
|
"label": r["d"].get("date", ""),
|
|
"node_type": "AcademicDay",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": False,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"AcademicWeek children failed: {e}")
|
|
return []
|
|
|
|
# AcademicTerm → weeks
|
|
if node_type == "AcademicTerm" and neo4j_db_name:
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (t:AcademicTerm {uuid_string: $id})"
|
|
"-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) "
|
|
"RETURN w ORDER BY toInteger(w.academic_week_number)",
|
|
id=neo4j_node_id,
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r["w"]["uuid_string"],
|
|
"label": "Week {}".format(r["w"].get("academic_week_number", r["w"].get("week_number", "?"))),
|
|
"node_type": "AcademicWeek",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"section_id": section_id if section_id == "timetable-term" else "",
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"AcademicTerm children failed: {e}")
|
|
return []
|
|
|
|
# SubjectClass → lessons
|
|
if node_type == "SubjectClass" and neo4j_db_name:
|
|
try:
|
|
with driver_tools.get_session(database=neo4j_db_name) as session:
|
|
result = session.run(
|
|
"MATCH (c:SubjectClass {uuid_string: $id})-[:CLASS_HAS_LESSON]->(l) "
|
|
"RETURN l ORDER BY l.date, l.start_time LIMIT 50",
|
|
id=neo4j_node_id,
|
|
)
|
|
return [
|
|
{
|
|
"neo4j_node_id": r["l"]["uuid_string"],
|
|
"label": r["l"].get("title") or r["l"].get("period_code") or "Lesson",
|
|
"node_type": "TimetableLesson",
|
|
"neo4j_db_name": neo4j_db_name,
|
|
"is_section": False,
|
|
"has_children": False,
|
|
}
|
|
for r in result
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"Class lessons query failed: {e}")
|
|
|
|
return []
|
|
|
|
|
|
# ─── Endpoints ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/tree")
|
|
async def get_teacher_graph_tree(
|
|
credentials: dict = Depends(SupabaseBearer()),
|
|
) -> Dict[str, Any]:
|
|
user_id = credentials.get("sub", "")
|
|
user_email = credentials.get("email", "")
|
|
if not user_id:
|
|
raise HTTPException(status_code=403, detail="Could not extract user_id from token")
|
|
|
|
teacher_db = _user_to_teacher_db(user_id)
|
|
|
|
user_node = _query_user_node(teacher_db) or {
|
|
"neo4j_node_id": user_id,
|
|
"label": "My Workspace",
|
|
"node_type": "User",
|
|
"neo4j_db_name": teacher_db,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
}
|
|
|
|
supabase_institute_id, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email)
|
|
|
|
sections = [
|
|
_build_calendar_section(),
|
|
_build_timetable_section(user_id, supabase_institute_id, institute_db, teacher_node_uuid),
|
|
_build_classes_section(user_id, supabase_institute_id, institute_db),
|
|
_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]:
|
|
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")
|
|
_require_allowed_neo4j_db(neo4j_db_name, node_type, section_id, user_id, user_email)
|
|
children = _get_children_for_node(neo4j_node_id, neo4j_db_name, node_type, section_id, user_email)
|
|
return {"status": "success", "children": children}
|
|
|
|
|
|
@router.get("/calendar/academic")
|
|
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
|
user_id = credentials.get("sub", "")
|
|
user_email = credentials.get("email", "")
|
|
_, institute_db, _ = _resolve_institute(user_id, user_email)
|
|
if not institute_db:
|
|
return {"status": "no_school", "terms": []}
|
|
try:
|
|
with driver_tools.get_session(database=institute_db) as s:
|
|
if not s.run("MATCH (ay:AcademicYear) RETURN ay LIMIT 1").single():
|
|
return {"status": "empty", "terms": [], "institute_db": institute_db}
|
|
terms = []
|
|
for tr in s.run(
|
|
"MATCH (ay:AcademicYear)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t:AcademicTerm) "
|
|
"RETURN t ORDER BY toInteger(t.term_number)"
|
|
):
|
|
t = tr["t"]
|
|
with driver_tools.get_session(database=institute_db) as s2:
|
|
weeks = [{
|
|
"neo4j_node_id": w["w"]["uuid_string"],
|
|
"label": f"Week {w['w']['academic_week_number']}",
|
|
"node_type": "AcademicWeek",
|
|
"neo4j_db_name": institute_db,
|
|
"is_section": False,
|
|
"has_children": True,
|
|
} for w in s2.run(
|
|
"MATCH (t:AcademicTerm {uuid_string: $tid})-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w:AcademicWeek) "
|
|
"RETURN w ORDER BY toInteger(w.academic_week_number)",
|
|
tid=t["uuid_string"]
|
|
)]
|
|
terms.append({
|
|
"neo4j_node_id": t["uuid_string"],
|
|
"label": t["term_name"],
|
|
"node_type": "AcademicTerm",
|
|
"neo4j_db_name": institute_db,
|
|
"is_section": False,
|
|
"has_children": len(weeks) > 0,
|
|
"children": weeks,
|
|
})
|
|
return {"status": "populated", "terms": terms, "institute_db": institute_db}
|
|
except Exception as e:
|
|
logger.error(f"Academic calendar query failed: {e}")
|
|
return {"status": "error", "terms": []}
|