feat(phase-b): Supabase-first timetable, classes, enrollment, and student views

- timetable_builder_router: Supabase-primary slot write (POST /timetable/slots),
  week_cycle support, GET /slots reads from Supabase, materialize-periods endpoint,
  rebuild-neo4j endpoint, sync-lessons endpoint (Track B: TaughtLesson Neo4j nodes),
  _sync_teacher_timetables_to_neo4j and _sync_taught_lessons_to_neo4j helpers
- classes_router: GET /{class_id} enriched with profiles + enrollment_requests,
  GET /school/students for admin search, PATCH /enrollment-requests/{id} approve/reject
- taught_lessons_router: GET /student/lessons student week view with enrichment
- school_router: academic_periods sync, day-type management
- platform_admin_router + platform_admin: POST /admin/reset and /admin/seed endpoints
- invitations_router: teacher invite scaffolding
- reset_environment + seed_environment: idempotent dev environment scripts
- graph_tree_router: Supabase-first institute resolution
- provisioning_service: neo4j_private_db_name column support
- main.py + run/routers.py: register new routers

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 02:55:44 +01:00
co-authored by Claude Sonnet 4.6
parent 7c75481245
commit abf8d05ca1
13 changed files with 3906 additions and 203 deletions
+73 -18
View File
@@ -1,10 +1,10 @@
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
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()
@@ -16,6 +16,50 @@ 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 _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:
@@ -164,21 +208,31 @@ def _section(section_id: str, label: str, db: str, status: str,
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],
)
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(institute_db: Optional[str], teacher_uuid: Optional[str]) -> Dict:
@@ -508,7 +562,7 @@ async def get_teacher_graph_tree(
"has_children": True,
}
institute_db, teacher_node_uuid = _find_teacher_institute(user_email)
_, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email)
sections = [
_build_calendar_section(),
@@ -546,8 +600,9 @@ async def get_node_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, _ = _find_teacher_institute(user_email)
_, institute_db, _ = _resolve_institute(user_id, user_email)
if not institute_db:
return {"status": "no_school", "terms": []}
try: