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:
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Platform Admin Router — super_admin / platform_admin operations.
|
||||
|
||||
GET /admin/schools — list all institutes with member + calendar counts
|
||||
GET /admin/stats — platform-level summary
|
||||
"""
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.auth.platform_admin import require_platform_admin
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
@router.get("/schools")
|
||||
async def list_all_schools(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""List every institute with basic counts. Platform admin only."""
|
||||
sb = _sb()
|
||||
|
||||
institutes = (
|
||||
sb.supabase.table("institutes")
|
||||
.select("id,name,urn,website,status,created_at,neo4j_uuid_string")
|
||||
.order("name")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
if not institutes:
|
||||
return {"status": "ok", "schools": [], "total": 0}
|
||||
|
||||
inst_ids = [i["id"] for i in institutes]
|
||||
|
||||
# Member counts per institute
|
||||
all_members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("institute_id,role")
|
||||
.in_("institute_id", inst_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import defaultdict
|
||||
member_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: {"staff": 0, "students": 0})
|
||||
staff_roles = {"teacher", "school_admin", "department_head"}
|
||||
for m in all_members:
|
||||
iid = m["institute_id"]
|
||||
if m["role"] in staff_roles:
|
||||
member_counts[iid]["staff"] += 1
|
||||
elif m["role"] == "student":
|
||||
member_counts[iid]["students"] += 1
|
||||
|
||||
# Calendar presence per institute
|
||||
term_rows = (
|
||||
sb.supabase.table("academic_terms")
|
||||
.select("institute_id")
|
||||
.in_("institute_id", inst_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
has_calendar = {r["institute_id"] for r in term_rows}
|
||||
|
||||
# Pending invitations count
|
||||
inv_rows = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("institute_id")
|
||||
.eq("status", "pending")
|
||||
.in_("institute_id", inst_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
from collections import Counter
|
||||
inv_counts = Counter(r["institute_id"] for r in inv_rows)
|
||||
|
||||
schools = []
|
||||
for inst in institutes:
|
||||
iid = inst["id"]
|
||||
mc = member_counts.get(iid, {})
|
||||
schools.append({
|
||||
**inst,
|
||||
"staff_count": mc.get("staff", 0),
|
||||
"student_count": mc.get("students", 0),
|
||||
"has_calendar": iid in has_calendar,
|
||||
"pending_invitations": inv_counts.get(iid, 0),
|
||||
})
|
||||
|
||||
return {"status": "ok", "schools": schools, "total": len(schools)}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def platform_stats(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""High-level platform counts. Platform admin only."""
|
||||
sb = _sb()
|
||||
|
||||
inst_count = len(
|
||||
sb.supabase.table("institutes").select("id").execute().data or []
|
||||
)
|
||||
profile_count = len(
|
||||
sb.supabase.table("profiles").select("id").execute().data or []
|
||||
)
|
||||
lesson_count = len(
|
||||
sb.supabase.table("taught_lessons").select("id").execute().data or []
|
||||
)
|
||||
inv_count = len(
|
||||
sb.supabase.table("invitations").select("id").eq("status", "pending").execute().data or []
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"schools": inst_count,
|
||||
"profiles": profile_count,
|
||||
"taught_lessons": lesson_count,
|
||||
"pending_invitations": inv_count,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_environment(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""DESTRUCTIVE: wipe all test data. Neo4j + Supabase. Platform admin only."""
|
||||
import asyncio
|
||||
from run.initialization.reset_environment import reset as _reset
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _reset)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed_environment(
|
||||
_: dict = Depends(require_platform_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent rebuild: both schools, global calendar, 20 test accounts. Platform admin only."""
|
||||
import asyncio
|
||||
from run.initialization.seed_environment import seed as _seed
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _seed)
|
||||
return {"status": "ok", **result}
|
||||
Reference in New Issue
Block a user