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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user