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 <noreply@anthropic.com>
This commit is contained in:
parent
7c75481245
commit
abf8d05ca1
10
main.py
10
main.py
@ -368,6 +368,7 @@ Startup modes:
|
|||||||
infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)
|
infra - Setup infrastructure (Neo4j schema, calendar, Supabase buckets)
|
||||||
demo-school - Create demo school (KevlarAI)
|
demo-school - Create demo school (KevlarAI)
|
||||||
demo-users - Create demo users
|
demo-users - Create demo users
|
||||||
|
seed-test - Seed full test environment (2 schools, all test users)
|
||||||
gais-data - Import GAIS data (Edubase, etc.)
|
gais-data - Import GAIS data (Edubase, etc.)
|
||||||
dev - Run development server with auto-reload
|
dev - Run development server with auto-reload
|
||||||
prod - Run production server (for Docker/containerized deployment)
|
prod - Run production server (for Docker/containerized deployment)
|
||||||
@ -376,7 +377,7 @@ Startup modes:
|
|||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--mode', '-m',
|
'--mode', '-m',
|
||||||
choices=['infra', 'demo-school', 'demo-users', 'gais-data', 'dev', 'prod'],
|
choices=['infra', 'demo-school', 'demo-users', 'seed-test', 'gais-data', 'dev', 'prod'],
|
||||||
default='dev',
|
default='dev',
|
||||||
help='Startup mode (default: dev)'
|
help='Startup mode (default: dev)'
|
||||||
)
|
)
|
||||||
@ -409,6 +410,13 @@ if __name__ == "__main__":
|
|||||||
success = run_demo_users_mode()
|
success = run_demo_users_mode()
|
||||||
sys.exit(0 if success else 1)
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
elif args.mode == 'seed-test':
|
||||||
|
from run.initialization.seed_test_environment import seed_test_environment
|
||||||
|
import json
|
||||||
|
result = seed_test_environment()
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
sys.exit(0 if result.get('success') else 1)
|
||||||
|
|
||||||
elif args.mode == 'gais-data':
|
elif args.mode == 'gais-data':
|
||||||
# Run GAIS data import
|
# Run GAIS data import
|
||||||
success = run_gais_data_mode()
|
success = run_gais_data_mode()
|
||||||
|
|||||||
57
modules/auth/platform_admin.py
Normal file
57
modules/auth/platform_admin.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
FastAPI dependencies for platform-level admin access.
|
||||||
|
|
||||||
|
Two tiers:
|
||||||
|
require_platform_admin — user must be in admin_profiles
|
||||||
|
require_super_admin — user must have is_super_admin=True in admin_profiles
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/admin/schools")
|
||||||
|
async def list_all_schools(admin=Depends(require_platform_admin)):
|
||||||
|
...
|
||||||
|
|
||||||
|
@router.post("/admin/provision")
|
||||||
|
async def provision(admin=Depends(require_super_admin)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from modules.auth.supabase_bearer import SupabaseBearer
|
||||||
|
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||||
|
|
||||||
|
|
||||||
|
def _sb() -> SupabaseServiceRoleClient:
|
||||||
|
return SupabaseServiceRoleClient()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_platform_admin(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> dict:
|
||||||
|
"""Require the caller to be a registered platform admin (in admin_profiles)."""
|
||||||
|
user_id = credentials.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid token")
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
result = (
|
||||||
|
sb.supabase.table("admin_profiles")
|
||||||
|
.select("id,admin_role,is_super_admin")
|
||||||
|
.eq("id", user_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=403, detail="Platform admin access required")
|
||||||
|
return {**credentials, "admin_profile": result.data}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=403, detail="Platform admin access required")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_super_admin(
|
||||||
|
admin: dict = Depends(require_platform_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Require the caller to have is_super_admin=True."""
|
||||||
|
if not admin.get("admin_profile", {}).get("is_super_admin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Super admin access required")
|
||||||
|
return admin
|
||||||
@ -229,6 +229,7 @@ class ProvisioningService:
|
|||||||
"neo4j_private_db_name": school_db,
|
"neo4j_private_db_name": school_db,
|
||||||
"neo4j_private_sync_status": "ready",
|
"neo4j_private_sync_status": "ready",
|
||||||
"neo4j_private_sync_at": datetime.utcnow().isoformat(),
|
"neo4j_private_sync_at": datetime.utcnow().isoformat(),
|
||||||
|
"neo4j_uuid_string": self._sanitize_component(institute_id),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
(
|
(
|
||||||
|
|||||||
620
routers/database/tools/classes_router.py
Normal file
620
routers/database/tools/classes_router.py
Normal file
@ -0,0 +1,620 @@
|
|||||||
|
"""
|
||||||
|
Classes Router — Supabase-backed CRUD for the `classes` table.
|
||||||
|
Institute members can read their institute's classes.
|
||||||
|
School admins and teachers can create classes.
|
||||||
|
School admins manage teacher/student assignments; teachers can leave/enroll.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _sb() -> SupabaseServiceRoleClient:
|
||||||
|
return SupabaseServiceRoleClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||||
|
"""Return the Supabase institute UUID for this user via profiles.school_id."""
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||||
|
return str((p.data or {}).get("school_id") or "")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_institute(user_id: str) -> str:
|
||||||
|
"""Return institute_id or raise 400."""
|
||||||
|
institute_id = _resolve_institute_id(user_id)
|
||||||
|
if not institute_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||||
|
return institute_id
|
||||||
|
|
||||||
|
|
||||||
|
def _is_school_admin(user_id: str, institute_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
r = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("role")
|
||||||
|
.eq("profile_id", user_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.in_("role", ["school_admin", "department_head"])
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return bool(r.data)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Request models ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CreateClassRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
class_code: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
key_stage: Optional[str] = None
|
||||||
|
year_group: Optional[str] = None
|
||||||
|
academic_year: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateClassRequest(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
class_code: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
key_stage: Optional[str] = None
|
||||||
|
year_group: Optional[str] = None
|
||||||
|
academic_year: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddTeacherRequest(BaseModel):
|
||||||
|
teacher_id: str
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AddStudentRequest(BaseModel):
|
||||||
|
student_id: str
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_classes(
|
||||||
|
subject: Optional[str] = None,
|
||||||
|
school_year: Optional[str] = None,
|
||||||
|
academic_year: Optional[str] = None,
|
||||||
|
key_stage: Optional[str] = None,
|
||||||
|
year_group: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
active_only: bool = True,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
q = sb.supabase.table("classes").select("*", count="exact").eq("institute_id", institute_id)
|
||||||
|
if active_only:
|
||||||
|
q = q.eq("is_active", True)
|
||||||
|
if subject:
|
||||||
|
q = q.eq("subject", subject)
|
||||||
|
# support both param names
|
||||||
|
yr = academic_year or school_year
|
||||||
|
if yr:
|
||||||
|
q = q.eq("academic_year", yr)
|
||||||
|
if key_stage:
|
||||||
|
q = q.eq("key_stage", key_stage)
|
||||||
|
if year_group:
|
||||||
|
q = q.eq("year_group", year_group)
|
||||||
|
if search:
|
||||||
|
q = q.ilike("name", f"%{search}%")
|
||||||
|
|
||||||
|
q = q.order("name").range(skip, skip + limit - 1)
|
||||||
|
res = q.execute()
|
||||||
|
|
||||||
|
classes = res.data or []
|
||||||
|
total = res.count or 0
|
||||||
|
|
||||||
|
# Attach student/teacher counts
|
||||||
|
class_ids = [c["id"] for c in classes]
|
||||||
|
teacher_counts: Dict[str, int] = {}
|
||||||
|
student_counts: Dict[str, int] = {}
|
||||||
|
if class_ids:
|
||||||
|
try:
|
||||||
|
tc = (
|
||||||
|
sb.supabase.table("class_teachers")
|
||||||
|
.select("class_id", count="exact")
|
||||||
|
.in_("class_id", class_ids)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
for row in (tc.data or []):
|
||||||
|
teacher_counts[row["class_id"]] = teacher_counts.get(row["class_id"], 0) + 1
|
||||||
|
|
||||||
|
sc = (
|
||||||
|
sb.supabase.table("class_students")
|
||||||
|
.select("class_id")
|
||||||
|
.in_("class_id", class_ids)
|
||||||
|
.eq("status", "active")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
for row in (sc.data or []):
|
||||||
|
student_counts[row["class_id"]] = student_counts.get(row["class_id"], 0) + 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
enriched = [
|
||||||
|
{**c, "teacher_count": teacher_counts.get(c["id"], 0), "student_count": student_counts.get(c["id"], 0)}
|
||||||
|
for c in classes
|
||||||
|
]
|
||||||
|
return {"classes": enriched, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/teacher")
|
||||||
|
async def my_teaching_classes(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
assigned = (
|
||||||
|
sb.supabase.table("class_teachers")
|
||||||
|
.select("class_id, is_primary")
|
||||||
|
.eq("teacher_id", user_id)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
if not assigned:
|
||||||
|
return {"classes": []}
|
||||||
|
|
||||||
|
class_ids = [a["class_id"] for a in assigned]
|
||||||
|
is_primary_map = {a["class_id"]: a["is_primary"] for a in assigned}
|
||||||
|
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("*")
|
||||||
|
.in_("id", class_ids)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("is_active", True)
|
||||||
|
.order("name")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
enriched = [{**c, "is_primary_teacher": is_primary_map.get(c["id"], False)} for c in res]
|
||||||
|
return {"classes": enriched}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/student")
|
||||||
|
async def my_student_classes(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
enrolled = (
|
||||||
|
sb.supabase.table("class_students")
|
||||||
|
.select("class_id")
|
||||||
|
.eq("student_id", user_id)
|
||||||
|
.eq("status", "active")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
if not enrolled:
|
||||||
|
return {"classes": []}
|
||||||
|
|
||||||
|
class_ids = [e["class_id"] for e in enrolled]
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("*")
|
||||||
|
.in_("id", class_ids)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("is_active", True)
|
||||||
|
.order("name")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
return {"classes": res}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{class_id}")
|
||||||
|
async def get_class(
|
||||||
|
class_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
cls_res = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", class_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not cls_res.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Class not found")
|
||||||
|
|
||||||
|
teachers = (
|
||||||
|
sb.supabase.table("class_teachers")
|
||||||
|
.select("teacher_id, is_primary, can_edit, assigned_at")
|
||||||
|
.eq("class_id", class_id)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
students = (
|
||||||
|
sb.supabase.table("class_students")
|
||||||
|
.select("student_id, status, enrolled_at")
|
||||||
|
.eq("class_id", class_id)
|
||||||
|
.eq("status", "active")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich with profile data
|
||||||
|
all_ids = [t["teacher_id"] for t in teachers] + [s["student_id"] for s in students]
|
||||||
|
profile_map: Dict[str, Dict] = {}
|
||||||
|
if all_ids:
|
||||||
|
profiles = (
|
||||||
|
sb.supabase.table("profiles")
|
||||||
|
.select("id, full_name, display_name, email, user_type")
|
||||||
|
.in_("id", list(set(all_ids)))
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
profile_map = {p["id"]: p for p in profiles}
|
||||||
|
|
||||||
|
for t in teachers:
|
||||||
|
t["profile"] = profile_map.get(t["teacher_id"], {})
|
||||||
|
for s in students:
|
||||||
|
s["profile"] = profile_map.get(s["student_id"], {})
|
||||||
|
|
||||||
|
# Enrollment requests (pending)
|
||||||
|
reqs = (
|
||||||
|
sb.supabase.table("enrollment_requests")
|
||||||
|
.select("id, student_id, status, created_at")
|
||||||
|
.eq("class_id", class_id)
|
||||||
|
.eq("status", "pending")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
req_student_ids = [r["student_id"] for r in reqs if r.get("student_id")]
|
||||||
|
req_profiles: Dict[str, Dict] = {}
|
||||||
|
if req_student_ids:
|
||||||
|
rp = (
|
||||||
|
sb.supabase.table("profiles")
|
||||||
|
.select("id, full_name, email")
|
||||||
|
.in_("id", req_student_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
req_profiles = {p["id"]: p for p in rp}
|
||||||
|
for r in reqs:
|
||||||
|
r["profile"] = req_profiles.get(r.get("student_id", ""), {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
**cls_res.data,
|
||||||
|
"teachers": teachers,
|
||||||
|
"students": students,
|
||||||
|
"enrollment_requests": reqs,
|
||||||
|
"student_count": len(students),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_class(
|
||||||
|
body: CreateClassRequest,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"institute_id": institute_id,
|
||||||
|
"name": body.name,
|
||||||
|
"created_by": user_id,
|
||||||
|
}
|
||||||
|
for field in ("class_code", "subject", "key_stage", "year_group", "academic_year", "description"):
|
||||||
|
val = getattr(body, field)
|
||||||
|
if val is not None:
|
||||||
|
row[field] = val
|
||||||
|
|
||||||
|
res = sb.supabase.table("classes").insert(row).execute()
|
||||||
|
new_class = (res.data or [{}])[0]
|
||||||
|
class_id = new_class.get("id")
|
||||||
|
|
||||||
|
# Auto-assign creator as primary teacher if they have teacher role
|
||||||
|
if class_id:
|
||||||
|
try:
|
||||||
|
mem = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("role")
|
||||||
|
.eq("profile_id", user_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if (mem.data or {}).get("role") in ("teacher", "department_head"):
|
||||||
|
sb.supabase.table("class_teachers").insert({
|
||||||
|
"class_id": class_id,
|
||||||
|
"teacher_id": user_id,
|
||||||
|
"is_primary": True,
|
||||||
|
"assigned_by": user_id,
|
||||||
|
}).execute()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Class created: {class_id} '{body.name}' by {user_id}")
|
||||||
|
return new_class
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{class_id}")
|
||||||
|
async def update_class(
|
||||||
|
class_id: str,
|
||||||
|
body: UpdateClassRequest,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
# Must be school admin OR primary teacher of this class
|
||||||
|
is_admin = _is_school_admin(user_id, institute_id)
|
||||||
|
if not is_admin:
|
||||||
|
ct = (
|
||||||
|
sb.supabase.table("class_teachers")
|
||||||
|
.select("is_primary")
|
||||||
|
.eq("class_id", class_id)
|
||||||
|
.eq("teacher_id", user_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not (ct.data or {}).get("is_primary"):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins or the primary teacher can update this class")
|
||||||
|
|
||||||
|
updates = {k: v for k, v in body.dict().items() if v is not None}
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", class_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return (res.data or [{}])[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{class_id}")
|
||||||
|
async def delete_class(
|
||||||
|
class_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
|
||||||
|
if not _is_school_admin(user_id, institute_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins can delete classes")
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
sb.supabase.table("classes").update({"is_active": False}).eq("id", class_id).eq("institute_id", institute_id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{class_id}/teachers")
|
||||||
|
async def add_teacher(
|
||||||
|
class_id: str,
|
||||||
|
body: AddTeacherRequest,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
|
||||||
|
if not _is_school_admin(user_id, institute_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins can assign teachers")
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.supabase.table("class_teachers").upsert({
|
||||||
|
"class_id": class_id,
|
||||||
|
"teacher_id": body.teacher_id,
|
||||||
|
"is_primary": body.is_primary,
|
||||||
|
"assigned_by": user_id,
|
||||||
|
}, on_conflict="class_id,teacher_id").execute()
|
||||||
|
return {"status": "ok", "row": (res.data or [{}])[0]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{class_id}/teachers/{teacher_id}")
|
||||||
|
async def remove_teacher(
|
||||||
|
class_id: str,
|
||||||
|
teacher_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
|
||||||
|
if not _is_school_admin(user_id, institute_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins can remove teachers")
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
sb.supabase.table("class_teachers").delete().eq("class_id", class_id).eq("teacher_id", teacher_id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/school/students")
|
||||||
|
async def list_school_students(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List all students in the caller's school. Used by admin to add students to a class."""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
members = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("profile_id")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("role", "student")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
student_ids = [m["profile_id"] for m in members]
|
||||||
|
if not student_ids:
|
||||||
|
return {"students": []}
|
||||||
|
profiles = (
|
||||||
|
sb.supabase.table("profiles")
|
||||||
|
.select("id, full_name, display_name, email, user_type")
|
||||||
|
.in_("id", student_ids)
|
||||||
|
.order("full_name")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
return {"students": profiles}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{class_id}/students")
|
||||||
|
async def add_student(
|
||||||
|
class_id: str,
|
||||||
|
body: AddStudentRequest,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
|
||||||
|
if not _is_school_admin(user_id, institute_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins can enroll students")
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.supabase.table("class_students").upsert({
|
||||||
|
"class_id": class_id,
|
||||||
|
"student_id": body.student_id,
|
||||||
|
"status": "active",
|
||||||
|
"enrolled_by": user_id,
|
||||||
|
}, on_conflict="class_id,student_id").execute()
|
||||||
|
return {"status": "ok", "row": (res.data or [{}])[0]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{class_id}/students/{student_id}")
|
||||||
|
async def remove_student(
|
||||||
|
class_id: str,
|
||||||
|
student_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
|
||||||
|
if not _is_school_admin(user_id, institute_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins can remove students")
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
sb.supabase.table("class_students").delete().eq("class_id", class_id).eq("student_id", student_id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{class_id}/leave")
|
||||||
|
async def leave_class(
|
||||||
|
class_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _sb()
|
||||||
|
sb.supabase.table("class_students").update({"status": "inactive"}).eq("class_id", class_id).eq("student_id", user_id).execute()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{class_id}/enrollment-requests")
|
||||||
|
async def list_enrollment_requests(
|
||||||
|
class_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
reqs = (
|
||||||
|
sb.supabase.table("enrollment_requests")
|
||||||
|
.select("*")
|
||||||
|
.eq("class_id", class_id)
|
||||||
|
.eq("status", "pending")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
return {"requests": reqs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{class_id}/enroll")
|
||||||
|
async def request_enrollment(
|
||||||
|
class_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _sb()
|
||||||
|
res = sb.supabase.table("enrollment_requests").insert({
|
||||||
|
"class_id": class_id,
|
||||||
|
"student_id": user_id,
|
||||||
|
"status": "pending",
|
||||||
|
}).execute()
|
||||||
|
return {"status": "ok", "request": (res.data or [{}])[0]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{class_id}/enrollment-requests/{request_id}")
|
||||||
|
async def respond_enrollment_request(
|
||||||
|
class_id: str,
|
||||||
|
request_id: str,
|
||||||
|
body: dict,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Approve or reject a pending enrollment request. School admin only."""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
if not _is_school_admin(user_id, institute_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Only school admins can respond to enrollment requests")
|
||||||
|
|
||||||
|
action = body.get("action")
|
||||||
|
if action not in ("approve", "reject"):
|
||||||
|
raise HTTPException(status_code=400, detail="action must be 'approve' or 'reject'")
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
req = (
|
||||||
|
sb.supabase.table("enrollment_requests")
|
||||||
|
.select("student_id, status")
|
||||||
|
.eq("id", request_id)
|
||||||
|
.eq("class_id", class_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not req.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
if req.data["status"] != "pending":
|
||||||
|
raise HTTPException(status_code=400, detail="Request is not pending")
|
||||||
|
|
||||||
|
student_id = req.data["student_id"]
|
||||||
|
new_status = "accepted" if action == "approve" else "rejected"
|
||||||
|
sb.supabase.table("enrollment_requests").update({"status": new_status}).eq("id", request_id).execute()
|
||||||
|
|
||||||
|
if action == "approve":
|
||||||
|
sb.supabase.table("class_students").upsert({
|
||||||
|
"class_id": class_id,
|
||||||
|
"student_id": student_id,
|
||||||
|
"status": "active",
|
||||||
|
"enrolled_by": user_id,
|
||||||
|
}, on_conflict="class_id,student_id").execute()
|
||||||
|
|
||||||
|
return {"status": "ok", "action": action, "student_id": student_id}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from modules.logger_tool import initialise_logger
|
from modules.logger_tool import initialise_logger
|
||||||
from modules.auth.supabase_bearer import SupabaseBearer
|
from modules.auth.supabase_bearer import SupabaseBearer
|
||||||
import modules.database.tools.neo4j_driver_tools as driver_tools
|
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)
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -16,6 +16,50 @@ def _user_to_teacher_db(user_id: str) -> str:
|
|||||||
return f"cc.users.teacher.{user_id.replace('-', '')}"
|
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]]:
|
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."""
|
"""Return (institute_db_name, teacher_uuid) by matching worker_email in all institute DBs."""
|
||||||
if not user_email:
|
if not user_email:
|
||||||
@ -164,21 +208,31 @@ def _section(section_id: str, label: str, db: str, status: str,
|
|||||||
|
|
||||||
|
|
||||||
def _build_calendar_section() -> Dict:
|
def _build_calendar_section() -> Dict:
|
||||||
current_year = str(datetime.now().year)
|
try:
|
||||||
months = _query_calendar_months(current_year)
|
with driver_tools.get_session(database="classroomcopilot") as session:
|
||||||
calendar_year_node = {
|
rows = session.run(
|
||||||
"neo4j_node_id": current_year,
|
"MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
|
||||||
"label": current_year,
|
).data()
|
||||||
"node_type": "CalendarYear",
|
if not rows:
|
||||||
"neo4j_db_name": "classroomcopilot",
|
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
||||||
"is_section": False,
|
year_nodes = [
|
||||||
"has_children": True,
|
{
|
||||||
"children": months,
|
"neo4j_node_id": r["y"]["uuid_string"],
|
||||||
}
|
"label": r["y"].get("year") or r["y"]["uuid_string"],
|
||||||
return _section(
|
"node_type": "CalendarYear",
|
||||||
"calendar", "Calendar", "classroomcopilot", "populated",
|
"neo4j_db_name": "classroomcopilot",
|
||||||
has_children=True, children=[calendar_year_node],
|
"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:
|
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,
|
"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 = [
|
sections = [
|
||||||
_build_calendar_section(),
|
_build_calendar_section(),
|
||||||
@ -546,8 +600,9 @@ async def get_node_children(
|
|||||||
|
|
||||||
@router.get("/calendar/academic")
|
@router.get("/calendar/academic")
|
||||||
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
async def get_academic_calendar(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
user_email = credentials.get("email", "")
|
user_email = credentials.get("email", "")
|
||||||
institute_db, _ = _find_teacher_institute(user_email)
|
_, institute_db, _ = _resolve_institute(user_id, user_email)
|
||||||
if not institute_db:
|
if not institute_db:
|
||||||
return {"status": "no_school", "terms": []}
|
return {"status": "no_school", "terms": []}
|
||||||
try:
|
try:
|
||||||
|
|||||||
372
routers/database/tools/invitations_router.py
Normal file
372
routers/database/tools/invitations_router.py
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
"""
|
||||||
|
Invitations & People Router.
|
||||||
|
|
||||||
|
POST /users/invite — school admin sends magic-link invitation
|
||||||
|
GET /users/invitations — list invitations for the school
|
||||||
|
DELETE /users/invitations/{id} — cancel a pending invitation
|
||||||
|
POST /users/invitations/{id}/resend — re-send magic link
|
||||||
|
GET /users/staff — list current staff members
|
||||||
|
GET /users/students — list current student members
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from modules.logger_tool import initialise_logger
|
||||||
|
from modules.auth.supabase_bearer import SupabaseBearer
|
||||||
|
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||||
|
|
||||||
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
VALID_ROLES = {"teacher", "student", "school_admin", "department_head"}
|
||||||
|
STAFF_ROLES = {"teacher", "school_admin", "department_head"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sb() -> SupabaseServiceRoleClient:
|
||||||
|
return SupabaseServiceRoleClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||||
|
return str((p.data or {}).get("school_id") or "") or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_institute(user_id: str) -> str:
|
||||||
|
iid = _resolve_institute_id(user_id)
|
||||||
|
if not iid:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||||
|
return iid
|
||||||
|
|
||||||
|
|
||||||
|
def _require_school_admin(user_id: str, institute_id: str) -> None:
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
m = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("role")
|
||||||
|
.eq("profile_id", user_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
role = (m.data or {}).get("role", "")
|
||||||
|
if role not in ("school_admin", "department_head"):
|
||||||
|
raise HTTPException(status_code=403, detail="School admin access required")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=403, detail="Could not verify school admin status")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Request models ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class InviteRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
role: str # teacher | student | school_admin | department_head
|
||||||
|
metadata: Optional[Dict[str, Any]] = None # year_group, subject, department, etc.
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invite ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/invite")
|
||||||
|
async def invite_user(
|
||||||
|
body: InviteRequest,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create an invitation row and send a Supabase magic-link invite email.
|
||||||
|
Idempotent: if a pending invitation already exists for the same email/school,
|
||||||
|
it is returned (use /resend to refresh the magic link).
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
_require_school_admin(user_id, institute_id)
|
||||||
|
|
||||||
|
if body.role not in VALID_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"role must be one of {sorted(VALID_ROLES)}")
|
||||||
|
|
||||||
|
email = body.email.strip().lower()
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
# Check for existing pending invitation
|
||||||
|
existing = (
|
||||||
|
sb.supabase.table("invitations")
|
||||||
|
.select("id,status,email,role,created_at,expires_at")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("email", email)
|
||||||
|
.eq("status", "pending")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return {
|
||||||
|
"status": "already_pending",
|
||||||
|
"invitation": existing[0],
|
||||||
|
"message": "A pending invitation already exists. Use /resend to refresh the magic link.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Insert invitation row
|
||||||
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||||
|
inv_data = {
|
||||||
|
"institute_id": institute_id,
|
||||||
|
"email": email,
|
||||||
|
"role": body.role,
|
||||||
|
"invited_by": user_id,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"status": "pending",
|
||||||
|
"metadata": body.metadata or {},
|
||||||
|
}
|
||||||
|
inv_res = sb.supabase.table("invitations").insert(inv_data).execute()
|
||||||
|
if not inv_res.data:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create invitation record")
|
||||||
|
invitation = inv_res.data[0]
|
||||||
|
|
||||||
|
# Send magic link via Supabase Auth admin
|
||||||
|
try:
|
||||||
|
sb.supabase.auth.admin.invite_user_by_email(
|
||||||
|
email,
|
||||||
|
options={
|
||||||
|
"data": {
|
||||||
|
"invitation_id": invitation["id"],
|
||||||
|
"institute_id": institute_id,
|
||||||
|
"role": body.role,
|
||||||
|
**(body.metadata or {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
magic_link_sent = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Magic link send failed for {email}: {e}")
|
||||||
|
magic_link_sent = False
|
||||||
|
|
||||||
|
logger.info(f"Invited {email} as {body.role} to school {institute_id} by {user_id}")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"invitation": invitation,
|
||||||
|
"magic_link_sent": magic_link_sent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── List invitations ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/invitations")
|
||||||
|
async def list_invitations(
|
||||||
|
role: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None, description="pending|accepted|expired|cancelled"),
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
_require_school_admin(user_id, institute_id)
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
q = (
|
||||||
|
sb.supabase.table("invitations")
|
||||||
|
.select("id,email,role,status,invited_by,expires_at,created_at,metadata")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.order("created_at", desc=True)
|
||||||
|
)
|
||||||
|
if role:
|
||||||
|
q = q.eq("role", role)
|
||||||
|
if status:
|
||||||
|
q = q.eq("status", status)
|
||||||
|
|
||||||
|
rows = q.execute().data or []
|
||||||
|
|
||||||
|
# Mark expired rows (status still 'pending' but past expires_at)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
if row["status"] == "pending":
|
||||||
|
try:
|
||||||
|
exp = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00"))
|
||||||
|
if exp < now:
|
||||||
|
row["status"] = "expired"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"status": "ok", "invitations": rows, "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Cancel invitation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.delete("/invitations/{invitation_id}")
|
||||||
|
async def cancel_invitation(
|
||||||
|
invitation_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
_require_school_admin(user_id, institute_id)
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("invitations")
|
||||||
|
.update({"status": "cancelled"})
|
||||||
|
.eq("id", invitation_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("status", "pending")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not res.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Pending invitation not found")
|
||||||
|
return {"status": "ok", "invitation": res.data[0]}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Resend invitation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/invitations/{invitation_id}/resend")
|
||||||
|
async def resend_invitation(
|
||||||
|
invitation_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Re-trigger the magic link for a pending (or expired) invitation."""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
_require_school_admin(user_id, institute_id)
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
inv = (
|
||||||
|
sb.supabase.table("invitations")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", invitation_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
).data
|
||||||
|
if not inv:
|
||||||
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||||
|
if inv["status"] == "accepted":
|
||||||
|
raise HTTPException(status_code=400, detail="Invitation already accepted")
|
||||||
|
if inv["status"] == "cancelled":
|
||||||
|
raise HTTPException(status_code=400, detail="Invitation was cancelled — create a new one")
|
||||||
|
|
||||||
|
# Refresh expiry + status
|
||||||
|
new_expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||||
|
sb.supabase.table("invitations").update({
|
||||||
|
"expires_at": new_expires,
|
||||||
|
"status": "pending",
|
||||||
|
}).eq("id", invitation_id).execute()
|
||||||
|
|
||||||
|
# Re-send magic link
|
||||||
|
try:
|
||||||
|
sb.supabase.auth.admin.invite_user_by_email(
|
||||||
|
inv["email"],
|
||||||
|
options={
|
||||||
|
"data": {
|
||||||
|
"invitation_id": invitation_id,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
"role": inv["role"],
|
||||||
|
**inv.get("metadata", {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
magic_link_sent = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Resend magic link failed for {inv['email']}: {e}")
|
||||||
|
magic_link_sent = False
|
||||||
|
|
||||||
|
return {"status": "ok", "magic_link_sent": magic_link_sent, "new_expires_at": new_expires}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Staff list ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/staff")
|
||||||
|
async def list_staff(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List all staff members (teachers, admins, department heads) in the school."""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
_require_school_admin(user_id, institute_id)
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
members = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("profile_id,role,joined_at")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.in_("role", list(STAFF_ROLES))
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
if not members:
|
||||||
|
return {"status": "ok", "staff": [], "total": 0}
|
||||||
|
|
||||||
|
profile_ids = [m["profile_id"] for m in members]
|
||||||
|
profiles = (
|
||||||
|
sb.supabase.table("profiles")
|
||||||
|
.select("id,email,username,display_name")
|
||||||
|
.in_("id", profile_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
profile_map = {p["id"]: p for p in profiles}
|
||||||
|
|
||||||
|
staff = []
|
||||||
|
for m in members:
|
||||||
|
p = profile_map.get(m["profile_id"], {})
|
||||||
|
staff.append({
|
||||||
|
"profile_id": m["profile_id"],
|
||||||
|
"email": p.get("email"),
|
||||||
|
"username": p.get("username"),
|
||||||
|
"display_name": p.get("display_name"),
|
||||||
|
"role": m["role"],
|
||||||
|
"joined_at": m["joined_at"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"status": "ok", "staff": staff, "total": len(staff)}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Student list ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/students")
|
||||||
|
async def list_students(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List all student members in the school."""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
_require_school_admin(user_id, institute_id)
|
||||||
|
|
||||||
|
sb = _sb()
|
||||||
|
members = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("profile_id,role,joined_at")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("role", "student")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
if not members:
|
||||||
|
return {"status": "ok", "students": [], "total": 0}
|
||||||
|
|
||||||
|
profile_ids = [m["profile_id"] for m in members]
|
||||||
|
profiles = (
|
||||||
|
sb.supabase.table("profiles")
|
||||||
|
.select("id,email,username,display_name")
|
||||||
|
.in_("id", profile_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
profile_map = {p["id"]: p for p in profiles}
|
||||||
|
|
||||||
|
students = []
|
||||||
|
for m in members:
|
||||||
|
p = profile_map.get(m["profile_id"], {})
|
||||||
|
students.append({
|
||||||
|
"profile_id": m["profile_id"],
|
||||||
|
"email": p.get("email"),
|
||||||
|
"username": p.get("username"),
|
||||||
|
"display_name": p.get("display_name"),
|
||||||
|
"role": m["role"],
|
||||||
|
"joined_at": m["joined_at"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"status": "ok", "students": students, "total": len(students)}
|
||||||
148
routers/database/tools/platform_admin_router.py
Normal file
148
routers/database/tools/platform_admin_router.py
Normal file
@ -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}
|
||||||
@ -318,3 +318,286 @@ def _ensure_membership(sb: SupabaseServiceRoleClient, user_id: str, school_id: s
|
|||||||
"institute_id": school_id,
|
"institute_id": school_id,
|
||||||
"role": role,
|
"role": role,
|
||||||
}).execute()
|
}).execute()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── School Overview ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/overview")
|
||||||
|
async def get_school_overview(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Summary dashboard for school admins: staff/student/class counts,
|
||||||
|
calendar snapshot (terms, total academic days, current/next term).
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _get_sb()
|
||||||
|
|
||||||
|
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||||
|
school_id = str((p.data or {}).get("school_id") or "")
|
||||||
|
if not school_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||||
|
|
||||||
|
# Role check
|
||||||
|
mem = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("role")
|
||||||
|
.eq("profile_id", user_id)
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
user_role = (mem.data or {}).get("role", "teacher")
|
||||||
|
|
||||||
|
# Counts
|
||||||
|
staff_roles = ["teacher", "school_admin", "department_head"]
|
||||||
|
staff_rows = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("profile_id", count="exact")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.in_("role", staff_roles)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
student_rows = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("profile_id", count="exact")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.eq("role", "student")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
class_rows = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("id", count="exact")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.eq("is_active", True)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendar snapshot from academic_terms
|
||||||
|
terms = (
|
||||||
|
sb.supabase.table("academic_terms")
|
||||||
|
.select("id,term_name,term_number,start_date,end_date")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.order("term_number")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Academic day counts per term
|
||||||
|
if terms:
|
||||||
|
term_ids = [t["id"] for t in terms]
|
||||||
|
day_counts_res = (
|
||||||
|
sb.supabase.table("academic_days")
|
||||||
|
.select("academic_term_id", count="exact")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.eq("day_type", "Academic")
|
||||||
|
.in_("academic_term_id", term_ids)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
# Supabase doesn't group-by server-side; count manually per term
|
||||||
|
all_days = (
|
||||||
|
sb.supabase.table("academic_days")
|
||||||
|
.select("academic_term_id,day_type")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.in_("academic_term_id", term_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
from collections import defaultdict
|
||||||
|
academic_day_count: Dict[str, int] = defaultdict(int)
|
||||||
|
total_day_count: Dict[str, int] = defaultdict(int)
|
||||||
|
for d in all_days:
|
||||||
|
total_day_count[d["academic_term_id"]] += 1
|
||||||
|
if d["day_type"] == "Academic":
|
||||||
|
academic_day_count[d["academic_term_id"]] += 1
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
today_str = str(date.today())
|
||||||
|
for t in terms:
|
||||||
|
t["academic_days"] = academic_day_count.get(t["id"], 0)
|
||||||
|
t["total_days"] = total_day_count.get(t["id"], 0)
|
||||||
|
if t["start_date"] <= today_str <= t["end_date"]:
|
||||||
|
t["is_current"] = True
|
||||||
|
else:
|
||||||
|
t["is_current"] = False
|
||||||
|
|
||||||
|
pending_invites = (
|
||||||
|
sb.supabase.table("invitations")
|
||||||
|
.select("id", count="exact")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.eq("status", "pending")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"user_role": user_role,
|
||||||
|
"counts": {
|
||||||
|
"staff": staff_rows.count or 0,
|
||||||
|
"students": student_rows.count or 0,
|
||||||
|
"classes": class_rows.count or 0,
|
||||||
|
"pending_invitations": pending_invites.count or 0,
|
||||||
|
},
|
||||||
|
"terms": terms,
|
||||||
|
"has_calendar": len(terms) > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Calendar days (admin view) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/calendar/days")
|
||||||
|
async def list_calendar_days(
|
||||||
|
term_id: Optional[str] = None,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return academic_days for the school, optionally filtered by term.
|
||||||
|
Includes week_cycle from the parent academic_week.
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _get_sb()
|
||||||
|
|
||||||
|
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||||
|
school_id = str((p.data or {}).get("school_id") or "")
|
||||||
|
if not school_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||||
|
|
||||||
|
q = (
|
||||||
|
sb.supabase.table("academic_days")
|
||||||
|
.select("id,date,day_of_week,day_type,academic_week_id,academic_term_id,academic_day_number,excluded_period_codes")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.order("date")
|
||||||
|
)
|
||||||
|
if term_id:
|
||||||
|
q = q.eq("academic_term_id", term_id)
|
||||||
|
|
||||||
|
days = q.execute().data or []
|
||||||
|
|
||||||
|
# Enrich with week_cycle
|
||||||
|
if days:
|
||||||
|
week_ids = list({d["academic_week_id"] for d in days if d.get("academic_week_id")})
|
||||||
|
weeks = (
|
||||||
|
sb.supabase.table("academic_weeks")
|
||||||
|
.select("id,week_number,week_cycle")
|
||||||
|
.in_("id", week_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
wk_map = {w["id"]: w for w in weeks}
|
||||||
|
for d in days:
|
||||||
|
wk = wk_map.get(d.get("academic_week_id", ""), {})
|
||||||
|
d["week_cycle"] = wk.get("week_cycle", "")
|
||||||
|
d["week_number"] = wk.get("week_number")
|
||||||
|
|
||||||
|
return {"status": "ok", "days": days, "total": len(days)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/calendar/days/{day_id}")
|
||||||
|
async def update_calendar_day(
|
||||||
|
day_id: str,
|
||||||
|
body: Dict[str, Any],
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Override day_type for a single academic day (school admin only).
|
||||||
|
Syncs academic_periods: removes periods for non-Academic days,
|
||||||
|
creates periods from periods_template for newly-Academic days.
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _get_sb()
|
||||||
|
|
||||||
|
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||||
|
school_id = str((p.data or {}).get("school_id") or "")
|
||||||
|
if not school_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||||
|
|
||||||
|
# Verify admin role
|
||||||
|
mem = (
|
||||||
|
sb.supabase.table("institute_memberships")
|
||||||
|
.select("role")
|
||||||
|
.eq("profile_id", user_id)
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if (mem.data or {}).get("role") not in ("school_admin", "department_head"):
|
||||||
|
raise HTTPException(status_code=403, detail="School admin access required")
|
||||||
|
|
||||||
|
# Verify day belongs to school
|
||||||
|
day = (
|
||||||
|
sb.supabase.table("academic_days")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", day_id)
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
).data
|
||||||
|
if not day:
|
||||||
|
raise HTTPException(status_code=404, detail="Day not found")
|
||||||
|
|
||||||
|
new_day_type = body.get("day_type", day["day_type"])
|
||||||
|
excluded = body.get("excluded_period_codes", day.get("excluded_period_codes") or [])
|
||||||
|
|
||||||
|
valid_types = {"Academic", "Holiday", "Staff", "OffTimetable"}
|
||||||
|
if new_day_type not in valid_types:
|
||||||
|
raise HTTPException(status_code=400, detail=f"day_type must be one of {sorted(valid_types)}")
|
||||||
|
|
||||||
|
# Update the day
|
||||||
|
sb.supabase.table("academic_days").update({
|
||||||
|
"day_type": new_day_type,
|
||||||
|
"excluded_period_codes": excluded,
|
||||||
|
}).eq("id", day_id).execute()
|
||||||
|
|
||||||
|
old_type = day["day_type"]
|
||||||
|
periods_changed = 0
|
||||||
|
|
||||||
|
if old_type == "Academic" and new_day_type != "Academic":
|
||||||
|
# Remove periods for this day
|
||||||
|
del_res = (
|
||||||
|
sb.supabase.table("academic_periods")
|
||||||
|
.delete()
|
||||||
|
.eq("academic_day_id", day_id)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
periods_changed = -(len(del_res.data or []))
|
||||||
|
|
||||||
|
elif old_type != "Academic" and new_day_type == "Academic":
|
||||||
|
# Create periods from template
|
||||||
|
stt = (
|
||||||
|
sb.supabase.table("school_timetables")
|
||||||
|
.select("periods_template")
|
||||||
|
.eq("institute_id", school_id)
|
||||||
|
.order("created_at", desc=True)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
template = (stt[0].get("periods_template") or []) if stt else []
|
||||||
|
skip = set(excluded)
|
||||||
|
new_periods = []
|
||||||
|
for period in template:
|
||||||
|
if period.get("code") in skip:
|
||||||
|
continue
|
||||||
|
new_periods.append({
|
||||||
|
"academic_day_id": day_id,
|
||||||
|
"institute_id": school_id,
|
||||||
|
"period_code": period["code"],
|
||||||
|
"period_name": period.get("name", period["code"]),
|
||||||
|
"start_time": period.get("start_time"),
|
||||||
|
"end_time": period.get("end_time"),
|
||||||
|
"period_type": period.get("period_type", "lesson"),
|
||||||
|
})
|
||||||
|
if new_periods:
|
||||||
|
ins_res = (
|
||||||
|
sb.supabase.table("academic_periods")
|
||||||
|
.upsert(new_periods, on_conflict="academic_day_id,period_code")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
periods_changed = len(ins_res.data or [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"day_id": day_id,
|
||||||
|
"new_day_type": new_day_type,
|
||||||
|
"periods_changed": periods_changed,
|
||||||
|
}
|
||||||
|
|||||||
601
routers/database/tools/taught_lessons_router.py
Normal file
601
routers/database/tools/taught_lessons_router.py
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
"""
|
||||||
|
Taught Lessons Router — materialization and lesson CRUD.
|
||||||
|
|
||||||
|
POST /materialize — slot template × academic_periods → taught_lessons rows
|
||||||
|
GET /lessons — teacher's lessons for a date range
|
||||||
|
GET /lessons/{id} — single lesson detail
|
||||||
|
PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _sb() -> SupabaseServiceRoleClient:
|
||||||
|
return SupabaseServiceRoleClient()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
sb = _sb()
|
||||||
|
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||||
|
return str((p.data or {}).get("school_id") or "") or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_institute(user_id: str) -> str:
|
||||||
|
iid = _resolve_institute_id(user_id)
|
||||||
|
if not iid:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||||
|
return iid
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Request models ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UpdateLessonRequest(BaseModel):
|
||||||
|
lesson_plan: Optional[Dict[str, Any]] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Materialize ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/materialize")
|
||||||
|
async def materialize_taught_lessons(
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Materialize taught_lessons from teacher_timetable_slots × academic_periods.
|
||||||
|
|
||||||
|
For each slot (day_of_week + period_code + week_cycle), find every
|
||||||
|
academic_period that falls on a matching day and week cycle, then
|
||||||
|
UPSERT a taught_lesson row and a whiteboard_room for it.
|
||||||
|
|
||||||
|
Safe to re-run; uses ON CONFLICT DO UPDATE.
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
# ── 1. Get teacher timetable ──────────────────────────────────────────────
|
||||||
|
tt_rows = (
|
||||||
|
sb.supabase.table("teacher_timetables")
|
||||||
|
.select("id")
|
||||||
|
.eq("profile_id", user_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.limit(1)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
if not tt_rows:
|
||||||
|
return {"status": "error", "message": "No teacher timetable found — run /timetable/init first"}
|
||||||
|
tt_id = tt_rows[0]["id"]
|
||||||
|
|
||||||
|
# ── 2. Get teacher's timetable slots ──────────────────────────────────────
|
||||||
|
slots = (
|
||||||
|
sb.supabase.table("teacher_timetable_slots")
|
||||||
|
.select("id,day_of_week,period_code,subject_class,start_time,end_time,week_cycle,class_id")
|
||||||
|
.eq("teacher_timetable_id", tt_id)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
if not slots:
|
||||||
|
return {"status": "ok", "created": 0, "updated": 0, "message": "No timetable slots found"}
|
||||||
|
|
||||||
|
# ── 3. Resolve class_ids for slots (match by subject_class name) ──────────
|
||||||
|
class_name_to_id: Dict[str, str] = {}
|
||||||
|
subject_names = list({s["subject_class"] for s in slots if s.get("subject_class")})
|
||||||
|
if subject_names:
|
||||||
|
try:
|
||||||
|
classes_res = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("id,name,class_code")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.eq("is_active", True)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
for c in classes_res:
|
||||||
|
if c.get("name"):
|
||||||
|
class_name_to_id[c["name"]] = c["id"]
|
||||||
|
if c.get("class_code"):
|
||||||
|
class_name_to_id[c["class_code"]] = c["id"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not resolve class names: {e}")
|
||||||
|
|
||||||
|
# slot lookup keyed by (day_of_week, period_code, week_cycle)
|
||||||
|
# week_cycle='' means applies to both A and B weeks
|
||||||
|
slot_map: Dict[Tuple[str, str, str], Dict] = {}
|
||||||
|
for slot in slots:
|
||||||
|
key = (slot["day_of_week"], slot["period_code"], slot.get("week_cycle", ""))
|
||||||
|
slot_map[key] = slot
|
||||||
|
|
||||||
|
# ── 4. Get all academic_periods with day + week info ──────────────────────
|
||||||
|
# Fetch academic_days and academic_weeks separately, then join in Python
|
||||||
|
days_res = (
|
||||||
|
sb.supabase.table("academic_days")
|
||||||
|
.select("id,date,day_of_week,academic_week_id,day_type")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
weeks_res = (
|
||||||
|
sb.supabase.table("academic_weeks")
|
||||||
|
.select("id,week_cycle")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
week_cycle_map = {w["id"]: w["week_cycle"] for w in weeks_res}
|
||||||
|
|
||||||
|
# Only academic days get lesson periods
|
||||||
|
academic_day_map = {
|
||||||
|
d["id"]: {**d, "week_cycle": week_cycle_map.get(d["academic_week_id"], "A")}
|
||||||
|
for d in days_res
|
||||||
|
if d["day_type"] == "Academic"
|
||||||
|
}
|
||||||
|
|
||||||
|
periods_res = (
|
||||||
|
sb.supabase.table("academic_periods")
|
||||||
|
.select("id,academic_day_id,period_code,period_name,start_time,end_time,period_type")
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 5. Match slots to periods ─────────────────────────────────────────────
|
||||||
|
to_upsert: List[Dict] = []
|
||||||
|
whiteboard_rows: List[Dict] = []
|
||||||
|
|
||||||
|
for period in periods_res:
|
||||||
|
day_info = academic_day_map.get(period["academic_day_id"])
|
||||||
|
if not day_info:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dow = day_info["day_of_week"]
|
||||||
|
week_cycle = day_info["week_cycle"]
|
||||||
|
pcode = period["period_code"]
|
||||||
|
d = str(day_info["date"])[:10]
|
||||||
|
|
||||||
|
# Check all matching slot patterns: exact week_cycle match or '' (both weeks)
|
||||||
|
matched_slot = (
|
||||||
|
slot_map.get((dow, pcode, week_cycle))
|
||||||
|
or slot_map.get((dow, pcode, ""))
|
||||||
|
)
|
||||||
|
if not matched_slot:
|
||||||
|
continue
|
||||||
|
|
||||||
|
subj_class = matched_slot.get("subject_class", "")
|
||||||
|
# Prefer the slot's own class_id, fall back to name-lookup
|
||||||
|
class_id = (
|
||||||
|
matched_slot.get("class_id")
|
||||||
|
or class_name_to_id.get(subj_class)
|
||||||
|
)
|
||||||
|
|
||||||
|
tl_id_hint = f"tl_{period['id']}" # deterministic for neo4j_node_id
|
||||||
|
to_upsert.append({
|
||||||
|
"academic_period_id": period["id"],
|
||||||
|
"teacher_timetable_slot_id": matched_slot["id"],
|
||||||
|
"class_id": class_id, # may be None
|
||||||
|
"teacher_id": user_id,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
"date": d,
|
||||||
|
"period_code": pcode,
|
||||||
|
"week_cycle": week_cycle,
|
||||||
|
"day_of_week": dow,
|
||||||
|
"status": "planned",
|
||||||
|
"neo4j_node_id": tl_id_hint,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not to_upsert:
|
||||||
|
return {"status": "ok", "created": 0, "updated": 0, "message": "No slot-period matches found"}
|
||||||
|
|
||||||
|
# ── 6. Batch-upsert taught_lessons ────────────────────────────────────────
|
||||||
|
BATCH = 100
|
||||||
|
created = 0
|
||||||
|
for i in range(0, len(to_upsert), BATCH):
|
||||||
|
chunk = to_upsert[i : i + BATCH]
|
||||||
|
try:
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("taught_lessons")
|
||||||
|
.upsert(chunk, on_conflict="academic_period_id,teacher_id")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
created += len(res.data or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"taught_lessons upsert chunk {i}: {e}")
|
||||||
|
|
||||||
|
# ── 7. Fetch newly-created taught_lesson ids, create whiteboard_rooms ─────
|
||||||
|
try:
|
||||||
|
tl_rows = (
|
||||||
|
sb.supabase.table("taught_lessons")
|
||||||
|
.select("id,date,period_code,class_id")
|
||||||
|
.eq("teacher_id", user_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.is_("whiteboard_room_id", "null")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build whiteboard_room rows
|
||||||
|
wr_rows = []
|
||||||
|
for tl in tl_rows:
|
||||||
|
class_name = class_name_to_id and next(
|
||||||
|
(name for name, cid in class_name_to_id.items() if cid == tl.get("class_id")),
|
||||||
|
tl.get("period_code", "")
|
||||||
|
)
|
||||||
|
wr_rows.append({
|
||||||
|
"user_id": user_id,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
"name": f"{tl['date']} {tl['period_code']}",
|
||||||
|
"context_type": "taught_lesson",
|
||||||
|
"context_id": tl["id"],
|
||||||
|
"storage_path": f"taught_lessons/{tl['id']}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Batch insert whiteboard_rooms
|
||||||
|
wr_ids: Dict[str, str] = {} # context_id → room_id
|
||||||
|
for i in range(0, len(wr_rows), BATCH):
|
||||||
|
chunk = wr_rows[i : i + BATCH]
|
||||||
|
try:
|
||||||
|
res = sb.supabase.table("whiteboard_rooms").insert(chunk).execute()
|
||||||
|
for row in (res.data or []):
|
||||||
|
if row.get("context_id"):
|
||||||
|
wr_ids[row["context_id"]] = row["id"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"whiteboard_rooms batch insert: {e}")
|
||||||
|
|
||||||
|
# Update taught_lessons with their whiteboard_room_id
|
||||||
|
for context_id, room_id in wr_ids.items():
|
||||||
|
try:
|
||||||
|
sb.supabase.table("taught_lessons").update(
|
||||||
|
{"whiteboard_room_id": room_id}
|
||||||
|
).eq("id", context_id).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"whiteboard_room_id update for {context_id}: {e}")
|
||||||
|
|
||||||
|
rooms_created = len(wr_ids)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Whiteboard room creation failed (non-fatal): {e}")
|
||||||
|
rooms_created = 0
|
||||||
|
|
||||||
|
logger.info(f"Materialized {created} taught_lessons, {rooms_created} whiteboard_rooms for teacher {user_id}")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"lessons_upserted": created,
|
||||||
|
"whiteboard_rooms_created": rooms_created,
|
||||||
|
"total_matches": len(to_upsert),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Lesson timeline ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/lessons")
|
||||||
|
async def get_lessons(
|
||||||
|
week_start: Optional[str] = None, # ISO date "YYYY-MM-DD" (Monday); defaults to current week
|
||||||
|
weeks: int = 1, # how many weeks to return (max 4)
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return taught_lessons for the teacher within a date range, grouped by date.
|
||||||
|
Defaults to the current week.
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _require_institute(user_id)
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
# Resolve week window
|
||||||
|
today = date.today()
|
||||||
|
if week_start:
|
||||||
|
try:
|
||||||
|
monday = datetime.strptime(week_start, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
monday = today - timedelta(days=today.weekday())
|
||||||
|
else:
|
||||||
|
monday = today - timedelta(days=today.weekday())
|
||||||
|
|
||||||
|
weeks = min(max(weeks, 1), 4)
|
||||||
|
friday = monday + timedelta(weeks=weeks, days=4)
|
||||||
|
|
||||||
|
lessons = (
|
||||||
|
sb.supabase.table("taught_lessons")
|
||||||
|
.select(
|
||||||
|
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
|
||||||
|
"class_id,academic_period_id"
|
||||||
|
)
|
||||||
|
.eq("teacher_id", user_id)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.gte("date", str(monday))
|
||||||
|
.lte("date", str(friday))
|
||||||
|
.order("date")
|
||||||
|
.order("period_code")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich with period time and class name
|
||||||
|
period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")]
|
||||||
|
class_ids = list({l["class_id"] for l in lessons if l.get("class_id")})
|
||||||
|
|
||||||
|
period_map: Dict[str, Dict] = {}
|
||||||
|
class_map: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
if period_ids:
|
||||||
|
prows = (
|
||||||
|
sb.supabase.table("academic_periods")
|
||||||
|
.select("id,period_name,start_time,end_time")
|
||||||
|
.in_("id", period_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
period_map = {r["id"]: r for r in prows}
|
||||||
|
|
||||||
|
if class_ids:
|
||||||
|
crows = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("id,name,class_code,subject,year_group")
|
||||||
|
.in_("id", class_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
class_map = {r["id"]: r for r in crows}
|
||||||
|
|
||||||
|
# Group by date
|
||||||
|
days_map: Dict[str, List[Dict]] = defaultdict(list)
|
||||||
|
for lesson in lessons:
|
||||||
|
p = period_map.get(lesson.get("academic_period_id", ""), {})
|
||||||
|
c = class_map.get(lesson.get("class_id", ""), {})
|
||||||
|
enriched = {
|
||||||
|
**lesson,
|
||||||
|
"period_name": p.get("period_name", lesson["period_code"]),
|
||||||
|
"start_time": p.get("start_time"),
|
||||||
|
"end_time": p.get("end_time"),
|
||||||
|
"class_name": c.get("name") or c.get("class_code"),
|
||||||
|
"subject": c.get("subject"),
|
||||||
|
"year_group": c.get("year_group"),
|
||||||
|
}
|
||||||
|
days_map[lesson["date"]].append(enriched)
|
||||||
|
|
||||||
|
# Build ordered list of days
|
||||||
|
days_list = []
|
||||||
|
current = monday
|
||||||
|
while current <= friday:
|
||||||
|
d_str = str(current)
|
||||||
|
days_list.append({
|
||||||
|
"date": d_str,
|
||||||
|
"day_of_week": current.strftime("%A"),
|
||||||
|
"is_today": current == today,
|
||||||
|
"lessons": days_map.get(d_str, []),
|
||||||
|
})
|
||||||
|
current += timedelta(days=1)
|
||||||
|
# Skip weekends
|
||||||
|
if current.weekday() >= 5:
|
||||||
|
current += timedelta(days=7 - current.weekday())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"week_start": str(monday),
|
||||||
|
"week_end": str(friday),
|
||||||
|
"days": days_list,
|
||||||
|
"total_lessons": len(lessons),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lessons/{lesson_id}")
|
||||||
|
async def get_lesson(
|
||||||
|
lesson_id: str,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("taught_lessons")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", lesson_id)
|
||||||
|
.eq("teacher_id", user_id)
|
||||||
|
.single()
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not res.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||||
|
return res.data
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/lessons/{lesson_id}")
|
||||||
|
async def update_lesson(
|
||||||
|
lesson_id: str,
|
||||||
|
body: UpdateLessonRequest,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Teacher updates their own lesson content: plan, notes, status."""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
updates: Dict[str, Any] = {}
|
||||||
|
if body.lesson_plan is not None:
|
||||||
|
updates["lesson_plan"] = body.lesson_plan
|
||||||
|
if body.notes is not None:
|
||||||
|
updates["notes"] = body.notes
|
||||||
|
if body.status is not None:
|
||||||
|
valid = {"planned", "in_progress", "completed", "cancelled", "substituted"}
|
||||||
|
if body.status not in valid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"status must be one of {valid}")
|
||||||
|
updates["status"] = body.status
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="Nothing to update")
|
||||||
|
|
||||||
|
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||||
|
res = (
|
||||||
|
sb.supabase.table("taught_lessons")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", lesson_id)
|
||||||
|
.eq("teacher_id", user_id)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not res.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found or access denied")
|
||||||
|
return res.data[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Student lesson view ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/student/lessons")
|
||||||
|
async def get_student_lessons(
|
||||||
|
week_start: Optional[str] = None,
|
||||||
|
weeks: int = 1,
|
||||||
|
credentials: dict = Depends(SupabaseBearer()),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return taught_lessons for a student's enrolled classes for a date range.
|
||||||
|
Grouped by date, Mon-Fri only.
|
||||||
|
"""
|
||||||
|
user_id = credentials.get("sub", "")
|
||||||
|
institute_id = _resolve_institute_id(user_id)
|
||||||
|
if not institute_id:
|
||||||
|
return {"status": "error", "message": "Not linked to a school"}
|
||||||
|
sb = _sb()
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
if week_start:
|
||||||
|
try:
|
||||||
|
monday = datetime.strptime(week_start, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
monday = today - timedelta(days=today.weekday())
|
||||||
|
else:
|
||||||
|
monday = today - timedelta(days=today.weekday())
|
||||||
|
|
||||||
|
weeks = min(max(weeks, 1), 4)
|
||||||
|
friday = monday + timedelta(weeks=weeks, days=4)
|
||||||
|
|
||||||
|
# Get student's active class memberships
|
||||||
|
memberships = (
|
||||||
|
sb.supabase.table("class_students")
|
||||||
|
.select("class_id")
|
||||||
|
.eq("student_id", user_id)
|
||||||
|
.eq("status", "active")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
class_ids = [m["class_id"] for m in memberships]
|
||||||
|
if not class_ids:
|
||||||
|
days_list = []
|
||||||
|
current = monday
|
||||||
|
while current <= friday:
|
||||||
|
days_list.append({
|
||||||
|
"date": str(current),
|
||||||
|
"day_of_week": current.strftime("%A"),
|
||||||
|
"is_today": current == today,
|
||||||
|
"lessons": [],
|
||||||
|
})
|
||||||
|
current += timedelta(days=1)
|
||||||
|
if current.weekday() >= 5:
|
||||||
|
current += timedelta(days=7 - current.weekday())
|
||||||
|
return {"status": "ok", "week_start": str(monday), "days": days_list, "total_lessons": 0}
|
||||||
|
|
||||||
|
# Query taught_lessons for those classes
|
||||||
|
lessons = (
|
||||||
|
sb.supabase.table("taught_lessons")
|
||||||
|
.select(
|
||||||
|
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
|
||||||
|
"class_id,academic_period_id,teacher_id"
|
||||||
|
)
|
||||||
|
.in_("class_id", class_ids)
|
||||||
|
.eq("institute_id", institute_id)
|
||||||
|
.gte("date", str(monday))
|
||||||
|
.lte("date", str(friday))
|
||||||
|
.order("date")
|
||||||
|
.order("period_code")
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich with period times, class name, teacher name
|
||||||
|
period_ids = [l["academic_period_id"] for l in lessons if l.get("academic_period_id")]
|
||||||
|
lesson_class_ids = list({l["class_id"] for l in lessons if l.get("class_id")})
|
||||||
|
teacher_ids = list({l["teacher_id"] for l in lessons if l.get("teacher_id")})
|
||||||
|
|
||||||
|
period_map: Dict[str, Dict] = {}
|
||||||
|
class_map: Dict[str, Dict] = {}
|
||||||
|
teacher_map: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
if period_ids:
|
||||||
|
prows = (
|
||||||
|
sb.supabase.table("academic_periods")
|
||||||
|
.select("id,period_name,start_time,end_time")
|
||||||
|
.in_("id", period_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
period_map = {r["id"]: r for r in prows}
|
||||||
|
|
||||||
|
if lesson_class_ids:
|
||||||
|
crows = (
|
||||||
|
sb.supabase.table("classes")
|
||||||
|
.select("id,name,class_code,subject,year_group")
|
||||||
|
.in_("id", lesson_class_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
class_map = {r["id"]: r for r in crows}
|
||||||
|
|
||||||
|
if teacher_ids:
|
||||||
|
trows = (
|
||||||
|
sb.supabase.table("profiles")
|
||||||
|
.select("id,full_name,display_name")
|
||||||
|
.in_("id", teacher_ids)
|
||||||
|
.execute()
|
||||||
|
.data or []
|
||||||
|
)
|
||||||
|
teacher_map = {r["id"]: r for r in trows}
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
days_map: Dict[str, List[Dict]] = defaultdict(list)
|
||||||
|
for lesson in lessons:
|
||||||
|
p = period_map.get(lesson.get("academic_period_id", ""), {})
|
||||||
|
c = class_map.get(lesson.get("class_id", ""), {})
|
||||||
|
t = teacher_map.get(lesson.get("teacher_id", ""), {})
|
||||||
|
enriched = {
|
||||||
|
**lesson,
|
||||||
|
"period_name": p.get("period_name", lesson["period_code"]),
|
||||||
|
"start_time": p.get("start_time"),
|
||||||
|
"end_time": p.get("end_time"),
|
||||||
|
"class_name": c.get("name") or c.get("class_code"),
|
||||||
|
"subject": c.get("subject"),
|
||||||
|
"year_group": c.get("year_group"),
|
||||||
|
"teacher_name": t.get("display_name") or t.get("full_name"),
|
||||||
|
}
|
||||||
|
days_map[lesson["date"]].append(enriched)
|
||||||
|
|
||||||
|
days_list = []
|
||||||
|
current = monday
|
||||||
|
while current <= friday:
|
||||||
|
d_str = str(current)
|
||||||
|
days_list.append({
|
||||||
|
"date": d_str,
|
||||||
|
"day_of_week": current.strftime("%A"),
|
||||||
|
"is_today": current == today,
|
||||||
|
"lessons": days_map.get(d_str, []),
|
||||||
|
})
|
||||||
|
current += timedelta(days=1)
|
||||||
|
if current.weekday() >= 5:
|
||||||
|
current += timedelta(days=7 - current.weekday())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"week_start": str(monday),
|
||||||
|
"week_end": str(friday),
|
||||||
|
"days": days_list,
|
||||||
|
"total_lessons": len(lessons),
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
172
run/initialization/reset_environment.py
Normal file
172
run/initialization/reset_environment.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
reset_environment.py — DESTRUCTIVE wipe of all non-permanent data.
|
||||||
|
|
||||||
|
Clears:
|
||||||
|
- Neo4j: drops cc.users.*, classroomcopilot; wipes cc.institutes.* content
|
||||||
|
- Supabase: deletes all test auth users + profiles + memberships
|
||||||
|
- Supabase: detaches kcar from any school
|
||||||
|
|
||||||
|
Safe invariants (never touched):
|
||||||
|
- gaisdata Neo4j DB
|
||||||
|
- system / neo4j Neo4j DBs
|
||||||
|
- kcar auth account and admin_profiles entry
|
||||||
|
- institutes rows (schools themselves are kept, just de-seeded)
|
||||||
|
|
||||||
|
Run from inside the ccapi container:
|
||||||
|
python3 -c "from run.initialization.reset_environment import reset; reset()"
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
from modules.logger_tool import initialise_logger
|
||||||
|
from modules.database.services.neo4j_service import Neo4jService
|
||||||
|
import modules.database.tools.neo4j_driver_tools as dt
|
||||||
|
|
||||||
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||||
|
|
||||||
|
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
|
||||||
|
KCAR_EMAIL = "kcar@kevlarai.com"
|
||||||
|
|
||||||
|
# Databases to fully DROP (content + structure)
|
||||||
|
DBS_TO_DROP = [
|
||||||
|
"classroomcopilot",
|
||||||
|
"cc.users",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Institute DBs — wipe content only (keep the DB, re-provision in seed)
|
||||||
|
INSTITUTE_DB_PREFIXES = ["cc.institutes."]
|
||||||
|
|
||||||
|
# Supabase connection details (direct REST, no SDK needed for admin auth ops)
|
||||||
|
def _sb_headers():
|
||||||
|
url = os.environ["SUPABASE_URL"]
|
||||||
|
key = os.environ["SERVICE_ROLE_KEY"]
|
||||||
|
return url, {"apikey": key, "Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _neo4j_drop_all_matching(pattern: str) -> List[str]:
|
||||||
|
"""Drop every Neo4j database whose name starts with pattern."""
|
||||||
|
dropped = []
|
||||||
|
with dt.get_session(database="system") as s:
|
||||||
|
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
||||||
|
targets = [db for db in all_dbs if db.startswith(pattern)]
|
||||||
|
for db in targets:
|
||||||
|
logger.info(f" DROP DATABASE `{db}`")
|
||||||
|
with dt.get_session(database="system") as s:
|
||||||
|
s.run(f"DROP DATABASE `{db}` IF EXISTS")
|
||||||
|
dropped.append(db)
|
||||||
|
return dropped
|
||||||
|
|
||||||
|
|
||||||
|
def _neo4j_wipe_institute_dbs() -> List[str]:
|
||||||
|
"""MATCH (n) DETACH DELETE on every cc.institutes.* database."""
|
||||||
|
wiped = []
|
||||||
|
with dt.get_session(database="system") as s:
|
||||||
|
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
||||||
|
targets = [db for db in all_dbs
|
||||||
|
if any(db.startswith(p) for p in INSTITUTE_DB_PREFIXES)
|
||||||
|
and not db.endswith(".curriculum")]
|
||||||
|
for db in targets:
|
||||||
|
logger.info(f" Wipe cc.institutes DB: {db}")
|
||||||
|
with dt.get_session(database=db) as s:
|
||||||
|
s.run("MATCH (n) DETACH DELETE n")
|
||||||
|
wiped.append(db)
|
||||||
|
# Also wipe curriculum DBs
|
||||||
|
with dt.get_session(database="system") as s:
|
||||||
|
all_dbs = [r["name"] for r in s.run("SHOW DATABASES YIELD name RETURN name")]
|
||||||
|
curriculum_dbs = [db for db in all_dbs if db.endswith(".curriculum")]
|
||||||
|
for db in curriculum_dbs:
|
||||||
|
logger.info(f" Wipe curriculum DB: {db}")
|
||||||
|
with dt.get_session(database=db) as s:
|
||||||
|
s.run("MATCH (n) DETACH DELETE n")
|
||||||
|
wiped.append(db)
|
||||||
|
return wiped
|
||||||
|
|
||||||
|
|
||||||
|
def _supabase_list_auth_users(url: str, headers: dict) -> List[Dict]:
|
||||||
|
r = requests.get(f"{url}/auth/v1/admin/users", headers=headers, params={"per_page": 200})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("users", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _supabase_delete_auth_user(url: str, headers: dict, uid: str):
|
||||||
|
r = requests.delete(f"{url}/auth/v1/admin/users/{uid}", headers=headers)
|
||||||
|
if r.status_code not in (200, 204):
|
||||||
|
logger.warning(f" Delete auth user {uid}: {r.status_code} {r.text[:100]}")
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> Dict[str, Any]:
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("RESET ENVIRONMENT — destructive wipe starting")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
results: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# ── 1. Neo4j: drop cc.users.* and classroomcopilot ───────────────────────
|
||||||
|
logger.info("\n[Neo4j] Dropping cc.users.* databases...")
|
||||||
|
dropped = _neo4j_drop_all_matching("cc.users")
|
||||||
|
logger.info(f" Dropped: {dropped}")
|
||||||
|
|
||||||
|
logger.info("[Neo4j] Dropping classroomcopilot...")
|
||||||
|
with dt.get_session(database="system") as s:
|
||||||
|
s.run("DROP DATABASE `classroomcopilot` IF EXISTS")
|
||||||
|
logger.info(" Done")
|
||||||
|
|
||||||
|
# ── 2. Neo4j: wipe institute DB content ──────────────────────────────────
|
||||||
|
logger.info("[Neo4j] Wiping cc.institutes.* content...")
|
||||||
|
wiped = _neo4j_wipe_institute_dbs()
|
||||||
|
logger.info(f" Wiped: {wiped}")
|
||||||
|
results["neo4j"] = {"dropped": dropped, "wiped": wiped}
|
||||||
|
|
||||||
|
# ── 3. Supabase: detach kcar from school ──────────────────────────────────
|
||||||
|
logger.info("\n[Supabase] Detaching kcar from school...")
|
||||||
|
url, headers = _sb_headers()
|
||||||
|
requests.patch(
|
||||||
|
f"{url}/rest/v1/profiles",
|
||||||
|
headers={**headers, "Prefer": "return=minimal"},
|
||||||
|
params={"id": f"eq.{KCAR_ID}"},
|
||||||
|
json={"school_id": None},
|
||||||
|
)
|
||||||
|
requests.delete(
|
||||||
|
f"{url}/rest/v1/institute_memberships",
|
||||||
|
headers=headers,
|
||||||
|
params={"profile_id": f"eq.{KCAR_ID}"},
|
||||||
|
)
|
||||||
|
logger.info(" kcar detached")
|
||||||
|
|
||||||
|
# ── 4. Supabase: delete all test users except kcar ────────────────────────
|
||||||
|
logger.info("[Supabase] Deleting test auth users...")
|
||||||
|
all_users = _supabase_list_auth_users(url, headers)
|
||||||
|
deleted_emails = []
|
||||||
|
for u in all_users:
|
||||||
|
if u["email"] == KCAR_EMAIL:
|
||||||
|
continue
|
||||||
|
_supabase_delete_auth_user(url, headers, u["id"])
|
||||||
|
deleted_emails.append(u["email"])
|
||||||
|
time.sleep(0.1)
|
||||||
|
logger.info(f" Deleted {len(deleted_emails)} auth users")
|
||||||
|
|
||||||
|
# profiles + memberships cascade via FK on auth.users deletion (Supabase handles it)
|
||||||
|
# but clean up explicitly to be safe
|
||||||
|
requests.delete(
|
||||||
|
f"{url}/rest/v1/profiles",
|
||||||
|
headers=headers,
|
||||||
|
params={"id": f"neq.{KCAR_ID}"},
|
||||||
|
)
|
||||||
|
requests.delete(
|
||||||
|
f"{url}/rest/v1/institute_memberships",
|
||||||
|
headers=headers,
|
||||||
|
params={"profile_id": f"neq.{KCAR_ID}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
results["supabase"] = {"deleted_users": deleted_emails}
|
||||||
|
|
||||||
|
logger.info("\n" + "=" * 60)
|
||||||
|
logger.info("RESET COMPLETE")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
print(json.dumps(reset(), indent=2, default=str))
|
||||||
439
run/initialization/seed_environment.py
Normal file
439
run/initialization/seed_environment.py
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
"""
|
||||||
|
seed_environment.py — idempotent full-environment rebuild.
|
||||||
|
|
||||||
|
Assumes reset_environment.py has already been run (or it's the first boot).
|
||||||
|
Safe to re-run: all writes use UPSERT / MERGE.
|
||||||
|
|
||||||
|
Schools
|
||||||
|
-------
|
||||||
|
KevlarAI 6585bf91-6ae8-4d72-ab54-cddf3ba4e648 kevlarai.test
|
||||||
|
Greenfield Academy a1b2c3d4-e5f6-7890-abcd-ef1234567890 greenfieldacademy.test
|
||||||
|
|
||||||
|
Uniform accounts per school (10 × 2 = 20 total)
|
||||||
|
------------------------------------------------
|
||||||
|
admin@{domain} school_admin
|
||||||
|
head@{domain} school_admin
|
||||||
|
physics@{domain} teacher
|
||||||
|
maths@{domain} teacher
|
||||||
|
teacher1@{domain} teacher
|
||||||
|
teacher2@{domain} teacher
|
||||||
|
teacher3@{domain} teacher
|
||||||
|
student1@{domain} student
|
||||||
|
student2@{domain} student
|
||||||
|
student3@{domain} student
|
||||||
|
|
||||||
|
Run from inside the ccapi container:
|
||||||
|
python3 -c "from run.initialization.seed_environment import seed; seed()"
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from modules.logger_tool import initialise_logger
|
||||||
|
|
||||||
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||||
|
|
||||||
|
# ─── School constants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
KEVLARAI_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
||||||
|
KEVLARAI_NAME = "KevlarAI"
|
||||||
|
KEVLARAI_URN = "KEVLARAI-001"
|
||||||
|
KEVLARAI_DOMAIN = "kevlarai.test"
|
||||||
|
|
||||||
|
GREENFIELD_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
GREENFIELD_NAME = "Greenfield Academy"
|
||||||
|
GREENFIELD_URN = "TEST-GFA-001"
|
||||||
|
GREENFIELD_DOMAIN = "greenfieldacademy.test"
|
||||||
|
|
||||||
|
# ─── Passwords ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PWD_ADMIN = "Admin@Cc2025!"
|
||||||
|
PWD_TEACHER = "Teacher@Cc2025!"
|
||||||
|
PWD_STUDENT = "Student@Cc2025!"
|
||||||
|
|
||||||
|
# ─── Account template ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _school_accounts(domain: str, institute_id: str) -> List[Dict]:
|
||||||
|
return [
|
||||||
|
# school_admin accounts
|
||||||
|
{
|
||||||
|
"prefix": "admin", "email": f"admin@{domain}",
|
||||||
|
"full_name": "Alex Admin", "display_name": "Alex",
|
||||||
|
"username": f"admin.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "school_admin", "password": PWD_ADMIN,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "head", "email": f"head@{domain}",
|
||||||
|
"full_name": "Helen Head", "display_name": "Helen",
|
||||||
|
"username": f"head.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "school_admin", "password": PWD_ADMIN,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
# teacher accounts
|
||||||
|
{
|
||||||
|
"prefix": "physics", "email": f"physics@{domain}",
|
||||||
|
"full_name": "Phil Physics", "display_name": "Phil",
|
||||||
|
"username": f"physics.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "teacher", "password": PWD_TEACHER,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "maths", "email": f"maths@{domain}",
|
||||||
|
"full_name": "Mary Maths", "display_name": "Mary",
|
||||||
|
"username": f"maths.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "teacher", "password": PWD_TEACHER,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "teacher1", "email": f"teacher1@{domain}",
|
||||||
|
"full_name": "Tom Teacher", "display_name": "Tom",
|
||||||
|
"username": f"teacher1.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "teacher", "password": PWD_TEACHER,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "teacher2", "email": f"teacher2@{domain}",
|
||||||
|
"full_name": "Tara Teach", "display_name": "Tara",
|
||||||
|
"username": f"teacher2.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "teacher", "password": PWD_TEACHER,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "teacher3", "email": f"teacher3@{domain}",
|
||||||
|
"full_name": "Tim Teachwell", "display_name": "Tim",
|
||||||
|
"username": f"teacher3.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "teacher", "role": "teacher", "password": PWD_TEACHER,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
# student accounts
|
||||||
|
{
|
||||||
|
"prefix": "student1", "email": f"student1@{domain}",
|
||||||
|
"full_name": "Sam Student", "display_name": "Sam",
|
||||||
|
"username": f"student1.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "student", "role": "student", "password": PWD_STUDENT,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "student2", "email": f"student2@{domain}",
|
||||||
|
"full_name": "Sophie Study", "display_name": "Sophie",
|
||||||
|
"username": f"student2.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "student", "role": "student", "password": PWD_STUDENT,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"prefix": "student3", "email": f"student3@{domain}",
|
||||||
|
"full_name": "Steve Scholar", "display_name": "Steve",
|
||||||
|
"username": f"student3.{domain.replace('.', '_')}",
|
||||||
|
"user_type": "student", "role": "student", "password": PWD_STUDENT,
|
||||||
|
"institute_id": institute_id,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_ACCOUNTS = (
|
||||||
|
_school_accounts(KEVLARAI_DOMAIN, KEVLARAI_ID) +
|
||||||
|
_school_accounts(GREENFIELD_DOMAIN, GREENFIELD_ID)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Supabase helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _sb_ctx():
|
||||||
|
url = os.environ["SUPABASE_URL"]
|
||||||
|
key = os.environ["SERVICE_ROLE_KEY"]
|
||||||
|
headers = {
|
||||||
|
"apikey": key,
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
return url, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_post(url, headers, path, data):
|
||||||
|
return requests.post(f"{url}/auth/v1/admin{path}", headers=headers, json=data)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_get(url, headers, path, params=None):
|
||||||
|
r = requests.get(f"{url}/auth/v1/admin{path}", headers=headers, params=params)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_upsert(url, headers, table, data, on_conflict):
|
||||||
|
h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"}
|
||||||
|
r = requests.post(
|
||||||
|
f"{url}/rest/v1/{table}",
|
||||||
|
headers=h,
|
||||||
|
json=data,
|
||||||
|
params={"on_conflict": on_conflict},
|
||||||
|
)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_patch(url, headers, table, match_col, match_val, data):
|
||||||
|
r = requests.patch(
|
||||||
|
f"{url}/rest/v1/{table}",
|
||||||
|
headers={**headers, "Prefer": "return=minimal"},
|
||||||
|
params={match_col: f"eq.{match_val}"},
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main seed function ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def seed() -> Dict[str, Any]:
|
||||||
|
from modules.database.services.provisioning_service import ProvisioningService
|
||||||
|
from modules.database.services.neo4j_service import Neo4jService
|
||||||
|
from modules.database.init.init_calendar import create_calendar
|
||||||
|
|
||||||
|
url, headers = _sb_ctx()
|
||||||
|
errors: List[str] = []
|
||||||
|
results: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# ── Step 1: Fix KevlarAI institute record ─────────────────────────────────
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("SEED ENVIRONMENT")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("\n[1] KevlarAI institute record...")
|
||||||
|
try:
|
||||||
|
r = _rest_upsert(url, headers, "institutes", {
|
||||||
|
"id": KEVLARAI_ID,
|
||||||
|
"name": KEVLARAI_NAME,
|
||||||
|
"urn": KEVLARAI_URN,
|
||||||
|
"status": "active",
|
||||||
|
"website": "https://kevlarai.com",
|
||||||
|
"address": {"line1": "1 AI Lane", "city": "London", "postcode": "EC1A 1BB"},
|
||||||
|
"metadata": {"headteacher": "Alex Admin", "seeded": True},
|
||||||
|
}, on_conflict="id")
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
logger.info(" KevlarAI upserted ✓")
|
||||||
|
else:
|
||||||
|
raise Exception(r.text[:200])
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"kevlarai_institute: {e}")
|
||||||
|
logger.error(f" {e}")
|
||||||
|
|
||||||
|
# ── Step 2: Create Greenfield Academy if needed ───────────────────────────
|
||||||
|
logger.info("[2] Greenfield Academy institute record...")
|
||||||
|
try:
|
||||||
|
neo4j_uuid_greenfield = GREENFIELD_ID.replace("-", "")
|
||||||
|
r = _rest_upsert(url, headers, "institutes", {
|
||||||
|
"id": GREENFIELD_ID,
|
||||||
|
"name": GREENFIELD_NAME,
|
||||||
|
"urn": GREENFIELD_URN,
|
||||||
|
"status": "active",
|
||||||
|
"website": "https://greenfieldacademy.test",
|
||||||
|
"address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"},
|
||||||
|
"metadata": {"headteacher": "Alex Admin", "seeded": True},
|
||||||
|
"neo4j_uuid_string": neo4j_uuid_greenfield,
|
||||||
|
}, on_conflict="id")
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
logger.info(" Greenfield Academy upserted ✓")
|
||||||
|
else:
|
||||||
|
raise Exception(r.text[:200])
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"greenfield_institute: {e}")
|
||||||
|
logger.error(f" {e}")
|
||||||
|
|
||||||
|
# ── Step 3: Provision Neo4j for both schools ──────────────────────────────
|
||||||
|
logger.info("[3] Neo4j school provisioning...")
|
||||||
|
provisioner = ProvisioningService()
|
||||||
|
school_dbs: Dict[str, str] = {}
|
||||||
|
|
||||||
|
for iid, name in [(KEVLARAI_ID, "KevlarAI"), (GREENFIELD_ID, "Greenfield Academy")]:
|
||||||
|
try:
|
||||||
|
result = provisioner.ensure_school(iid)
|
||||||
|
db = result["db_name"]
|
||||||
|
school_dbs[iid] = db
|
||||||
|
logger.info(f" {name}: {db} ✓")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"ensure_school {name}: {e}")
|
||||||
|
logger.error(f" {name}: {e}")
|
||||||
|
# derive fallback db name
|
||||||
|
school_dbs[iid] = f"cc.institutes.{iid.replace('-', '')}"
|
||||||
|
|
||||||
|
# ── Step 4: Rebuild classroomcopilot global calendar ─────────────────────
|
||||||
|
logger.info("[4] classroomcopilot global calendar (2024–2028)...")
|
||||||
|
try:
|
||||||
|
neo4j_svc = Neo4jService()
|
||||||
|
neo4j_svc.create_database("classroomcopilot")
|
||||||
|
logger.info(" DB created, waiting 5s for availability...")
|
||||||
|
time.sleep(5)
|
||||||
|
start_dt = datetime(2024, 1, 1)
|
||||||
|
end_dt = datetime(2028, 12, 31)
|
||||||
|
create_calendar("classroomcopilot", start_dt, end_dt)
|
||||||
|
logger.info(" Calendar built ✓")
|
||||||
|
results["global_calendar"] = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"global_calendar: {e}")
|
||||||
|
logger.error(f" {e}")
|
||||||
|
results["global_calendar"] = "error"
|
||||||
|
|
||||||
|
# ── Step 5: Create / verify auth users ────────────────────────────────────
|
||||||
|
logger.info("[5] Creating auth users (20 accounts)...")
|
||||||
|
try:
|
||||||
|
existing = _auth_get(url, headers, "/users", {"per_page": 200}).get("users", [])
|
||||||
|
existing_by_email = {u["email"]: u for u in existing}
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"list_auth_users: {e}")
|
||||||
|
existing_by_email = {}
|
||||||
|
|
||||||
|
created_users: Dict[str, str] = {} # email → uid
|
||||||
|
for spec in ALL_ACCOUNTS:
|
||||||
|
email = spec["email"]
|
||||||
|
if email in existing_by_email:
|
||||||
|
created_users[email] = existing_by_email[email]["id"]
|
||||||
|
logger.info(f" {email}: exists [{created_users[email][:8]}]")
|
||||||
|
continue
|
||||||
|
r = _auth_post(url, headers, "/users", {
|
||||||
|
"email": email,
|
||||||
|
"password": spec["password"],
|
||||||
|
"email_confirm": True,
|
||||||
|
"user_metadata": {
|
||||||
|
"username": spec["username"],
|
||||||
|
"full_name": spec["full_name"],
|
||||||
|
"display_name": spec["display_name"],
|
||||||
|
"user_type": spec["user_type"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
uid = r.json()["id"]
|
||||||
|
created_users[email] = uid
|
||||||
|
logger.info(f" {email}: created [{uid[:8]}]")
|
||||||
|
else:
|
||||||
|
errors.append(f"create {email}: {r.text[:150]}")
|
||||||
|
logger.error(f" {email}: {r.text[:150]}")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
results["users_created"] = len(created_users)
|
||||||
|
|
||||||
|
# ── Step 6: Upsert profiles and memberships ───────────────────────────────
|
||||||
|
logger.info("[6] Upserting profiles and memberships...")
|
||||||
|
for spec in ALL_ACCOUNTS:
|
||||||
|
uid = created_users.get(spec["email"])
|
||||||
|
if not uid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_rest_upsert(url, headers, "profiles", {
|
||||||
|
"id": uid,
|
||||||
|
"email": spec["email"],
|
||||||
|
"user_type": spec["user_type"],
|
||||||
|
"username": spec["username"],
|
||||||
|
"full_name": spec["full_name"],
|
||||||
|
"display_name": spec["display_name"],
|
||||||
|
"school_id": spec["institute_id"],
|
||||||
|
"neo4j_sync_status": "pending",
|
||||||
|
}, on_conflict="id")
|
||||||
|
_rest_upsert(url, headers, "institute_memberships", {
|
||||||
|
"profile_id": uid,
|
||||||
|
"institute_id": spec["institute_id"],
|
||||||
|
"role": spec["role"],
|
||||||
|
"metadata": {},
|
||||||
|
}, on_conflict="profile_id,institute_id")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"profile/membership {spec['email']}: {e}")
|
||||||
|
logger.error(f" {spec['email']}: {e}")
|
||||||
|
|
||||||
|
logger.info(" Profiles and memberships upserted ✓")
|
||||||
|
|
||||||
|
# ── Step 7: Merge Neo4j Teacher/Student nodes ─────────────────────────────
|
||||||
|
logger.info("[7] Merging Neo4j worker nodes...")
|
||||||
|
try:
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
||||||
|
|
||||||
|
# Group by institute DB
|
||||||
|
by_db: Dict[str, List[Dict]] = {}
|
||||||
|
for spec in ALL_ACCOUNTS:
|
||||||
|
uid = created_users.get(spec["email"])
|
||||||
|
if not uid:
|
||||||
|
continue
|
||||||
|
db = school_dbs.get(spec["institute_id"])
|
||||||
|
if not db:
|
||||||
|
continue
|
||||||
|
by_db.setdefault(db, []).append({**spec, "uid": uid})
|
||||||
|
|
||||||
|
for db, users in by_db.items():
|
||||||
|
with driver.session(database=db) as s:
|
||||||
|
for u in users:
|
||||||
|
label = "Teacher" if u["user_type"] == "teacher" else "Student"
|
||||||
|
s.run(
|
||||||
|
f"MERGE (n:{label} {{uuid_string: $uid}}) "
|
||||||
|
"SET n.worker_email = $email, "
|
||||||
|
" n.worker_name = $name, "
|
||||||
|
" n.worker_type = $utype",
|
||||||
|
uid=u["uid"], email=u["email"],
|
||||||
|
name=u["full_name"], utype=u["user_type"],
|
||||||
|
)
|
||||||
|
logger.info(f" [{db[:35]}] {len(users)} nodes merged ✓")
|
||||||
|
|
||||||
|
driver.close()
|
||||||
|
results["neo4j_nodes"] = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"neo4j_nodes: {e}")
|
||||||
|
logger.error(f" {e}")
|
||||||
|
results["neo4j_nodes"] = "error"
|
||||||
|
|
||||||
|
# ── Ensure kcar is a platform super-admin ─────────────────────────────────
|
||||||
|
logger.info("[8] Ensuring kcar platform admin record...")
|
||||||
|
KCAR_ID = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28"
|
||||||
|
try:
|
||||||
|
_rest_upsert(url, headers, "admin_profiles", {
|
||||||
|
"id": KCAR_ID,
|
||||||
|
"email": "kcar@kevlarai.com",
|
||||||
|
"role": "super_admin",
|
||||||
|
"permissions": ["all"],
|
||||||
|
"metadata": {"seeded": True},
|
||||||
|
}, on_conflict="id")
|
||||||
|
logger.info(" kcar → admin_profiles ✓")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"kcar_admin: {e}")
|
||||||
|
logger.error(f" {e}")
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────────────────────
|
||||||
|
results["success"] = len(errors) == 0
|
||||||
|
results["errors"] = errors
|
||||||
|
|
||||||
|
_print_credential_sheet(created_users)
|
||||||
|
|
||||||
|
logger.info("\n" + "=" * 60)
|
||||||
|
if errors:
|
||||||
|
logger.info(f"SEED COMPLETE with {len(errors)} error(s)")
|
||||||
|
for e in errors:
|
||||||
|
logger.info(f" ✗ {e}")
|
||||||
|
else:
|
||||||
|
logger.info("SEED COMPLETE — all steps succeeded")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _print_credential_sheet(created_users: Dict[str, str]):
|
||||||
|
PAD = 36
|
||||||
|
logger.info("\n" + "=" * 70)
|
||||||
|
logger.info("CREDENTIAL SHEET")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info(f" {'ROLE':<16} {'EMAIL':<{PAD}} PASSWORD")
|
||||||
|
logger.info(f" {'-'*14} {'-'*(PAD-2)} -----------")
|
||||||
|
logger.info(f" {'[platform admin]':<16} {'kcar@kevlarai.com':<{PAD}} KevlarAI2025!")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
for school_id, domain, label in [
|
||||||
|
(KEVLARAI_ID, KEVLARAI_DOMAIN, "KevlarAI"),
|
||||||
|
(GREENFIELD_ID, GREENFIELD_DOMAIN, "Greenfield Academy"),
|
||||||
|
]:
|
||||||
|
logger.info(f" [{label}]")
|
||||||
|
for spec in ALL_ACCOUNTS:
|
||||||
|
if spec["institute_id"] != school_id:
|
||||||
|
continue
|
||||||
|
uid = created_users.get(spec["email"], "—")
|
||||||
|
status = f"[{uid[:8]}]" if uid != "—" else "[MISSING]"
|
||||||
|
logger.info(f" {spec['role']:<16} {spec['email']:<{PAD}} {spec['password']} {status}")
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
print(json.dumps(seed(), indent=2, default=str))
|
||||||
@ -13,6 +13,10 @@ 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.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.timetable_builder_router import router as timetable_builder_router
|
||||||
from routers.database.tools.school_router import router as school_router
|
from routers.database.tools.school_router import router as school_router
|
||||||
|
from routers.database.tools.classes_router import router as classes_router
|
||||||
|
from routers.database.tools.taught_lessons_router import router as taught_lessons_router
|
||||||
|
from routers.database.tools.invitations_router import router as invitations_router
|
||||||
|
from routers.database.tools.platform_admin_router import router as platform_admin_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
|
||||||
@ -63,6 +67,10 @@ def register_routes(app: FastAPI):
|
|||||||
app.include_router(user_init_router, prefix="/user", tags=["User"])
|
app.include_router(user_init_router, prefix="/user", tags=["User"])
|
||||||
app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"])
|
app.include_router(timetable_builder_router, prefix="/timetable", tags=["Timetable"])
|
||||||
app.include_router(school_router, prefix="/school", tags=["School"])
|
app.include_router(school_router, prefix="/school", tags=["School"])
|
||||||
|
app.include_router(classes_router, prefix="/database/timetable/classes", tags=["Classes"])
|
||||||
|
app.include_router(taught_lessons_router, prefix="/timetable", tags=["Taught Lessons"])
|
||||||
|
app.include_router(invitations_router, prefix="/users", tags=["People"])
|
||||||
|
app.include_router(platform_admin_router, prefix="/admin", tags=["Platform Admin"])
|
||||||
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