Compare commits
No commits in common. "74b69e9f3c97cea9000bb7974f5c5db950b2d32a" and "dea0f73e0a6ae150423796177d0649d44a2e6601" have entirely different histories.
74b69e9f3c
...
dea0f73e0a
10
main.py
10
main.py
@ -368,7 +368,6 @@ 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)
|
||||||
@ -377,7 +376,7 @@ Startup modes:
|
|||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--mode', '-m',
|
'--mode', '-m',
|
||||||
choices=['infra', 'demo-school', 'demo-users', 'seed-test', 'gais-data', 'dev', 'prod'],
|
choices=['infra', 'demo-school', 'demo-users', 'gais-data', 'dev', 'prod'],
|
||||||
default='dev',
|
default='dev',
|
||||||
help='Startup mode (default: dev)'
|
help='Startup mode (default: dev)'
|
||||||
)
|
)
|
||||||
@ -410,13 +409,6 @@ 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()
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
"""
|
|
||||||
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,7 +229,6 @@ 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:
|
||||||
(
|
(
|
||||||
@ -263,7 +262,6 @@ class ProvisioningService:
|
|||||||
"admin": ("superadmin", "superadmin"),
|
"admin": ("superadmin", "superadmin"),
|
||||||
"super_admin": ("superadmin", "superadmin"),
|
"super_admin": ("superadmin", "superadmin"),
|
||||||
"superadmin": ("superadmin", "superadmin"),
|
"superadmin": ("superadmin", "superadmin"),
|
||||||
"platform_admin": ("superadmin", "superadmin"),
|
|
||||||
}
|
}
|
||||||
neo_user_type, worker_type = user_type_map.get(user_type_raw, (user_type_raw or "standard", user_type_raw or "standard"))
|
neo_user_type, worker_type = user_type_map.get(user_type_raw, (user_type_raw or "standard", user_type_raw or "standard"))
|
||||||
|
|
||||||
|
|||||||
@ -1,620 +0,0 @@
|
|||||||
"""
|
|
||||||
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,50 +16,6 @@ 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:
|
||||||
@ -208,31 +164,21 @@ def _section(section_id: str, label: str, db: str, status: str,
|
|||||||
|
|
||||||
|
|
||||||
def _build_calendar_section() -> Dict:
|
def _build_calendar_section() -> Dict:
|
||||||
try:
|
current_year = str(datetime.now().year)
|
||||||
with driver_tools.get_session(database="classroomcopilot") as session:
|
months = _query_calendar_months(current_year)
|
||||||
rows = session.run(
|
calendar_year_node = {
|
||||||
"MATCH (y:CalendarYear) RETURN y ORDER BY toInteger(y.year)"
|
"neo4j_node_id": current_year,
|
||||||
).data()
|
"label": current_year,
|
||||||
if not rows:
|
"node_type": "CalendarYear",
|
||||||
return _section("calendar", "Calendar", "classroomcopilot", "empty")
|
"neo4j_db_name": "classroomcopilot",
|
||||||
year_nodes = [
|
"is_section": False,
|
||||||
{
|
"has_children": True,
|
||||||
"neo4j_node_id": r["y"]["uuid_string"],
|
"children": months,
|
||||||
"label": r["y"].get("year") or r["y"]["uuid_string"],
|
}
|
||||||
"node_type": "CalendarYear",
|
return _section(
|
||||||
"neo4j_db_name": "classroomcopilot",
|
"calendar", "Calendar", "classroomcopilot", "populated",
|
||||||
"is_section": False,
|
has_children=True, children=[calendar_year_node],
|
||||||
"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:
|
||||||
@ -562,7 +508,7 @@ async def get_teacher_graph_tree(
|
|||||||
"has_children": True,
|
"has_children": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, institute_db, teacher_node_uuid = _resolve_institute(user_id, user_email)
|
institute_db, teacher_node_uuid = _find_teacher_institute(user_email)
|
||||||
|
|
||||||
sections = [
|
sections = [
|
||||||
_build_calendar_section(),
|
_build_calendar_section(),
|
||||||
@ -600,9 +546,8 @@ 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, _ = _resolve_institute(user_id, user_email)
|
institute_db, _ = _find_teacher_institute(user_email)
|
||||||
if not institute_db:
|
if not institute_db:
|
||||||
return {"status": "no_school", "terms": []}
|
return {"status": "no_school", "terms": []}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,372 +0,0 @@
|
|||||||
"""
|
|
||||||
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)}
|
|
||||||
@ -1,650 +0,0 @@
|
|||||||
import os
|
|
||||||
import uuid
|
|
||||||
import aiohttp
|
|
||||||
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()
|
|
||||||
|
|
||||||
_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
|
||||||
_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
|
||||||
_LLM_TIMEOUT = 60
|
|
||||||
|
|
||||||
VALID_STATUS = {"draft", "ready", "archived"}
|
|
||||||
VALID_FIELDS = {"objectives", "activity_description", "title"}
|
|
||||||
BLOOM_LEVELS = {"remember", "understand", "apply", "analyse", "evaluate", "create"}
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Supabase helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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 _is_creator(plan: Dict[str, Any], user_id: str) -> bool:
|
|
||||||
return str(plan.get("created_by", "")) == str(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _can_edit_plan(sb: SupabaseServiceRoleClient, plan_id: str, user_id: str) -> bool:
|
|
||||||
try:
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("created_by")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
return False
|
|
||||||
if str(plan_res.data.get("created_by", "")) == str(user_id):
|
|
||||||
return True
|
|
||||||
collab = (
|
|
||||||
sb.supabase.table("lesson_collaborators")
|
|
||||||
.select("can_edit")
|
|
||||||
.eq("planned_lesson_id", plan_id)
|
|
||||||
.eq("profile_id", user_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
return bool((collab.data or {}).get("can_edit", False))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Request models ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class CreatePlanRequest(BaseModel):
|
|
||||||
title: str
|
|
||||||
class_id: Optional[str] = None
|
|
||||||
subject: Optional[str] = None
|
|
||||||
year_group: Optional[str] = None
|
|
||||||
estimated_duration_minutes: Optional[int] = None
|
|
||||||
objectives: Optional[List[Dict[str, Any]]] = None
|
|
||||||
activities: Optional[List[Dict[str, Any]]] = None
|
|
||||||
status: Optional[str] = "draft"
|
|
||||||
tags: Optional[List[str]] = None
|
|
||||||
topic_code: Optional[str] = None
|
|
||||||
whiteboard_room_id: Optional[str] = None
|
|
||||||
course_id: Optional[str] = None
|
|
||||||
sequence_number: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePlanRequest(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
class_id: Optional[str] = None
|
|
||||||
subject: Optional[str] = None
|
|
||||||
year_group: Optional[str] = None
|
|
||||||
estimated_duration_minutes: Optional[int] = None
|
|
||||||
objectives: Optional[List[Dict[str, Any]]] = None
|
|
||||||
activities: Optional[List[Dict[str, Any]]] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
tags: Optional[List[str]] = None
|
|
||||||
topic_code: Optional[str] = None
|
|
||||||
whiteboard_room_id: Optional[str] = None
|
|
||||||
course_id: Optional[str] = None
|
|
||||||
sequence_number: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class DeliverPlanRequest(BaseModel):
|
|
||||||
taught_lesson_id: Optional[str] = None
|
|
||||||
class_id: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AddCollaboratorRequest(BaseModel):
|
|
||||||
profile_id: str
|
|
||||||
can_edit: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class SuggestRequest(BaseModel):
|
|
||||||
field: str
|
|
||||||
context: Optional[str] = None
|
|
||||||
activity_section: Optional[str] = None
|
|
||||||
objective_texts: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/plans")
|
|
||||||
async def list_plans(
|
|
||||||
class_id: Optional[str] = None,
|
|
||||||
status: Optional[str] = None,
|
|
||||||
subject: Optional[str] = None,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
q = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("*")
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
)
|
|
||||||
if class_id:
|
|
||||||
q = q.eq("class_id", class_id)
|
|
||||||
if status:
|
|
||||||
q = q.eq("status", status)
|
|
||||||
if subject:
|
|
||||||
q = q.eq("subject", subject)
|
|
||||||
|
|
||||||
owned = q.eq("created_by", user_id).execute().data or []
|
|
||||||
|
|
||||||
collab_rows = (
|
|
||||||
sb.supabase.table("lesson_collaborators")
|
|
||||||
.select("planned_lesson_id")
|
|
||||||
.eq("profile_id", user_id)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
collab_plan_ids = [r["planned_lesson_id"] for r in collab_rows]
|
|
||||||
|
|
||||||
collab_plans: List[Dict] = []
|
|
||||||
if collab_plan_ids:
|
|
||||||
q2 = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("*")
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.in_("id", collab_plan_ids)
|
|
||||||
)
|
|
||||||
if class_id:
|
|
||||||
q2 = q2.eq("class_id", class_id)
|
|
||||||
if status:
|
|
||||||
q2 = q2.eq("status", status)
|
|
||||||
if subject:
|
|
||||||
q2 = q2.eq("subject", subject)
|
|
||||||
collab_plans = q2.execute().data or []
|
|
||||||
|
|
||||||
seen_ids: set = set()
|
|
||||||
all_plans: List[Dict] = []
|
|
||||||
for p in owned + collab_plans:
|
|
||||||
if p["id"] not in seen_ids:
|
|
||||||
seen_ids.add(p["id"])
|
|
||||||
all_plans.append(p)
|
|
||||||
|
|
||||||
if not all_plans:
|
|
||||||
return {"plans": []}
|
|
||||||
|
|
||||||
plan_ids = [p["id"] for p in all_plans]
|
|
||||||
|
|
||||||
collab_counts: Dict[str, int] = {}
|
|
||||||
delivery_counts: Dict[str, int] = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
cc = (
|
|
||||||
sb.supabase.table("lesson_collaborators")
|
|
||||||
.select("planned_lesson_id")
|
|
||||||
.in_("planned_lesson_id", plan_ids)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
for r in cc:
|
|
||||||
collab_counts[r["planned_lesson_id"]] = collab_counts.get(r["planned_lesson_id"], 0) + 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
dc = (
|
|
||||||
sb.supabase.table("lesson_deliveries")
|
|
||||||
.select("planned_lesson_id")
|
|
||||||
.in_("planned_lesson_id", plan_ids)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
for r in dc:
|
|
||||||
delivery_counts[r["planned_lesson_id"]] = delivery_counts.get(r["planned_lesson_id"], 0) + 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
enriched = [
|
|
||||||
{
|
|
||||||
**p,
|
|
||||||
"collaborator_count": collab_counts.get(p["id"], 0),
|
|
||||||
"delivery_count": delivery_counts.get(p["id"], 0),
|
|
||||||
"is_owner": str(p.get("created_by", "")) == str(user_id),
|
|
||||||
}
|
|
||||||
for p in all_plans
|
|
||||||
]
|
|
||||||
return {"plans": enriched}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/plans")
|
|
||||||
async def create_plan(
|
|
||||||
body: CreatePlanRequest,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
if body.status and body.status not in VALID_STATUS:
|
|
||||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}")
|
|
||||||
|
|
||||||
row: Dict[str, Any] = {
|
|
||||||
"created_by": user_id,
|
|
||||||
"institute_id": institute_id,
|
|
||||||
"title": body.title,
|
|
||||||
"status": body.status or "draft",
|
|
||||||
"objectives": body.objectives or [],
|
|
||||||
"activities": body.activities or [],
|
|
||||||
"tags": body.tags or [],
|
|
||||||
}
|
|
||||||
for field in (
|
|
||||||
"class_id", "subject", "year_group", "estimated_duration_minutes",
|
|
||||||
"topic_code", "whiteboard_room_id", "course_id", "sequence_number",
|
|
||||||
):
|
|
||||||
val = getattr(body, field)
|
|
||||||
if val is not None:
|
|
||||||
row[field] = val
|
|
||||||
|
|
||||||
res = sb.supabase.table("planned_lessons").insert(row).execute()
|
|
||||||
plan = (res.data or [{}])[0]
|
|
||||||
logger.info(f"Lesson plan created: {plan.get('id')} '{body.title}' by {user_id}")
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plans/{plan_id}")
|
|
||||||
async def get_plan(
|
|
||||||
plan_id: str,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("*")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Lesson plan not found")
|
|
||||||
|
|
||||||
plan = plan_res.data
|
|
||||||
if not _is_creator(plan, user_id):
|
|
||||||
collab_check = (
|
|
||||||
sb.supabase.table("lesson_collaborators")
|
|
||||||
.select("can_edit")
|
|
||||||
.eq("planned_lesson_id", plan_id)
|
|
||||||
.eq("profile_id", user_id)
|
|
||||||
.execute()
|
|
||||||
.data
|
|
||||||
)
|
|
||||||
if collab_check is None:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
collab_rows = (
|
|
||||||
sb.supabase.table("lesson_collaborators")
|
|
||||||
.select("profile_id, can_edit, added_at")
|
|
||||||
.eq("planned_lesson_id", plan_id)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_ids = [r["profile_id"] for r in collab_rows]
|
|
||||||
profile_map: Dict[str, Dict] = {}
|
|
||||||
if profile_ids:
|
|
||||||
try:
|
|
||||||
profiles = (
|
|
||||||
sb.supabase.table("profiles")
|
|
||||||
.select("id, full_name, email")
|
|
||||||
.in_("id", profile_ids)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
profile_map = {p["id"]: p for p in profiles}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
collaborators = [
|
|
||||||
{
|
|
||||||
**r,
|
|
||||||
"full_name": profile_map.get(r["profile_id"], {}).get("full_name"),
|
|
||||||
"email": profile_map.get(r["profile_id"], {}).get("email"),
|
|
||||||
}
|
|
||||||
for r in collab_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
deliveries = (
|
|
||||||
sb.supabase.table("lesson_deliveries")
|
|
||||||
.select("*")
|
|
||||||
.eq("planned_lesson_id", plan_id)
|
|
||||||
.order("started_at", desc=True)
|
|
||||||
.limit(10)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
|
|
||||||
return {**plan, "collaborators": collaborators, "recent_deliveries": deliveries}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/plans/{plan_id}")
|
|
||||||
async def update_plan(
|
|
||||||
plan_id: str,
|
|
||||||
body: UpdatePlanRequest,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
if not _can_edit_plan(sb, plan_id, user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied — not creator or editor collaborator")
|
|
||||||
|
|
||||||
if body.status and body.status not in VALID_STATUS:
|
|
||||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}")
|
|
||||||
|
|
||||||
updates: Dict[str, Any] = {}
|
|
||||||
for field in (
|
|
||||||
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
|
|
||||||
"objectives", "activities", "status", "tags", "topic_code",
|
|
||||||
"whiteboard_room_id", "course_id", "sequence_number",
|
|
||||||
):
|
|
||||||
val = getattr(body, field)
|
|
||||||
if val is not None:
|
|
||||||
updates[field] = val
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
raise HTTPException(status_code=400, detail="Nothing to update")
|
|
||||||
|
|
||||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
|
||||||
res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.update(updates)
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
return res.data[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/plans/{plan_id}")
|
|
||||||
async def delete_plan(
|
|
||||||
plan_id: str,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("created_by")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
if not _is_creator(plan_res.data, user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Only the creator can delete a plan")
|
|
||||||
|
|
||||||
sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).execute()
|
|
||||||
sb.supabase.table("planned_lessons").delete().eq("id", plan_id).execute()
|
|
||||||
logger.info(f"Lesson plan deleted: {plan_id} by {user_id}")
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/plans/{plan_id}/deliver")
|
|
||||||
async def deliver_plan(
|
|
||||||
plan_id: str,
|
|
||||||
body: DeliverPlanRequest,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("id, whiteboard_room_id")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
|
|
||||||
if not _can_edit_plan(sb, plan_id, user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
row: Dict[str, Any] = {
|
|
||||||
"planned_lesson_id": plan_id,
|
|
||||||
"delivered_by": user_id,
|
|
||||||
"institute_id": institute_id,
|
|
||||||
"started_at": datetime.utcnow().isoformat(),
|
|
||||||
}
|
|
||||||
if body.taught_lesson_id:
|
|
||||||
row["taught_lesson_id"] = body.taught_lesson_id
|
|
||||||
if body.class_id:
|
|
||||||
row["class_id"] = body.class_id
|
|
||||||
if body.notes:
|
|
||||||
row["notes"] = body.notes
|
|
||||||
|
|
||||||
whiteboard_room_id = plan_res.data.get("whiteboard_room_id")
|
|
||||||
if whiteboard_room_id:
|
|
||||||
row["whiteboard_room_id"] = whiteboard_room_id
|
|
||||||
|
|
||||||
res = sb.supabase.table("lesson_deliveries").insert(row).execute()
|
|
||||||
delivery = (res.data or [{}])[0]
|
|
||||||
logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}")
|
|
||||||
return delivery
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plans/{plan_id}/deliveries")
|
|
||||||
async def list_deliveries(
|
|
||||||
plan_id: str,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("created_by")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
|
|
||||||
deliveries = (
|
|
||||||
sb.supabase.table("lesson_deliveries")
|
|
||||||
.select("*")
|
|
||||||
.eq("planned_lesson_id", plan_id)
|
|
||||||
.order("started_at", desc=True)
|
|
||||||
.execute()
|
|
||||||
.data or []
|
|
||||||
)
|
|
||||||
return {"deliveries": deliveries}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/plans/{plan_id}/collaborators")
|
|
||||||
async def add_collaborator(
|
|
||||||
plan_id: str,
|
|
||||||
body: AddCollaboratorRequest,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("created_by")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
if not _is_creator(plan_res.data, user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Only the creator can add collaborators")
|
|
||||||
|
|
||||||
res = (
|
|
||||||
sb.supabase.table("lesson_collaborators")
|
|
||||||
.upsert(
|
|
||||||
{
|
|
||||||
"planned_lesson_id": plan_id,
|
|
||||||
"profile_id": body.profile_id,
|
|
||||||
"can_edit": body.can_edit,
|
|
||||||
"added_at": datetime.utcnow().isoformat(),
|
|
||||||
},
|
|
||||||
on_conflict="planned_lesson_id,profile_id",
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
return {"status": "ok", "row": (res.data or [{}])[0]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/plans/{plan_id}/collaborators/{profile_id}")
|
|
||||||
async def remove_collaborator(
|
|
||||||
plan_id: str,
|
|
||||||
profile_id: str,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("created_by")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
if not _is_creator(plan_res.data, user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Only the creator can remove collaborators")
|
|
||||||
|
|
||||||
sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).eq("profile_id", profile_id).execute()
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/plans/{plan_id}/suggest")
|
|
||||||
async def suggest_field(
|
|
||||||
plan_id: str,
|
|
||||||
body: SuggestRequest,
|
|
||||||
credentials: dict = Depends(SupabaseBearer()),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
user_id = credentials.get("sub", "")
|
|
||||||
institute_id = _require_institute(user_id)
|
|
||||||
sb = _sb()
|
|
||||||
|
|
||||||
if body.field not in VALID_FIELDS:
|
|
||||||
raise HTTPException(status_code=400, detail=f"field must be one of {VALID_FIELDS}")
|
|
||||||
|
|
||||||
plan_res = (
|
|
||||||
sb.supabase.table("planned_lessons")
|
|
||||||
.select("*")
|
|
||||||
.eq("id", plan_id)
|
|
||||||
.eq("institute_id", institute_id)
|
|
||||||
.single()
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if not plan_res.data:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found")
|
|
||||||
|
|
||||||
if not _can_edit_plan(sb, plan_id, user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
plan = plan_res.data
|
|
||||||
subject = plan.get("subject") or "a UK secondary school subject"
|
|
||||||
year_group = plan.get("year_group") or "unspecified year group"
|
|
||||||
title = plan.get("title", "")
|
|
||||||
context = body.context or ""
|
|
||||||
|
|
||||||
context_part = ("Teacher notes: " + context + "\n") if context else ""
|
|
||||||
if body.field == "objectives":
|
|
||||||
existing = ""
|
|
||||||
if body.objective_texts:
|
|
||||||
existing = "\n".join("- " + t for t in body.objective_texts)
|
|
||||||
objectives_part = ("Existing objectives:\n" + existing + "\n") if existing else ""
|
|
||||||
prompt = (
|
|
||||||
"You are an expert UK secondary school teacher.\n"
|
|
||||||
"Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n"
|
|
||||||
+ objectives_part + context_part
|
|
||||||
+ "Suggest ONE clear, measurable learning objective for this lesson using Bloom's taxonomy. "
|
|
||||||
"Write only the objective text, no bullet point or prefix."
|
|
||||||
)
|
|
||||||
elif body.field == "activity_description":
|
|
||||||
section = body.activity_section or "Main"
|
|
||||||
prompt = (
|
|
||||||
"You are an expert UK secondary school teacher.\n"
|
|
||||||
"Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n"
|
|
||||||
"Activity section: " + section + ".\n"
|
|
||||||
+ context_part
|
|
||||||
+ "Write a concise, practical description (2-4 sentences) for a " + section + " activity "
|
|
||||||
"suitable for UK secondary pupils. Include what the teacher does and what pupils do."
|
|
||||||
)
|
|
||||||
else: # title
|
|
||||||
prompt = (
|
|
||||||
"You are an expert UK secondary school teacher.\n"
|
|
||||||
"Subject: " + subject + ", Year: " + year_group + ".\n"
|
|
||||||
+ context_part
|
|
||||||
+ "Suggest ONE engaging, clear lesson title for a UK secondary school lesson. "
|
|
||||||
"Write only the title, no explanation."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"model": _OLLAMA_MODEL,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
}
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
f"{_OLLAMA_URL}/api/generate",
|
|
||||||
json=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
timeout=aiohttp.ClientTimeout(total=_LLM_TIMEOUT),
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
body_text = await resp.text()
|
|
||||||
logger.error(f"Ollama suggest error ({resp.status}): {body_text}")
|
|
||||||
raise HTTPException(status_code=502, detail=f"LLM request failed: {resp.status}")
|
|
||||||
data = await resp.json()
|
|
||||||
suggestion = (data.get("response") or "").strip()
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"suggest_field LLM call failed for plan {plan_id}: {e}")
|
|
||||||
raise HTTPException(status_code=502, detail="LLM service unavailable")
|
|
||||||
|
|
||||||
return {"suggestion": suggestion}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
"""
|
|
||||||
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,286 +318,3 @@ 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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,601 +0,0 @@
|
|||||||
"""
|
|
||||||
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
@ -1,172 +0,0 @@
|
|||||||
"""
|
|
||||||
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))
|
|
||||||
@ -1,455 +0,0 @@
|
|||||||
"""
|
|
||||||
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.test",
|
|
||||||
"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}")
|
|
||||||
|
|
||||||
# Fix kcar's auth user_metadata so user_type is "platform_admin", not "teacher".
|
|
||||||
# Without this, POST /user/init assigns kcar to the default school on first login.
|
|
||||||
try:
|
|
||||||
r = requests.put(
|
|
||||||
f"{url}/auth/v1/admin/users/{KCAR_ID}",
|
|
||||||
headers=headers,
|
|
||||||
json={"user_metadata": {"user_type": "platform_admin"}},
|
|
||||||
)
|
|
||||||
if r.status_code in (200, 201):
|
|
||||||
logger.info(" kcar → user_type: platform_admin ✓")
|
|
||||||
else:
|
|
||||||
logger.warning(f" kcar user_metadata patch failed ({r.status_code}): {r.text[:120]}")
|
|
||||||
except Exception as e:
|
|
||||||
errors.append(f"kcar_user_type: {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))
|
|
||||||
@ -1,408 +0,0 @@
|
|||||||
"""
|
|
||||||
Seed Test Environment — idempotent full-environment setup for CC development.
|
|
||||||
|
|
||||||
Creates:
|
|
||||||
- kcar@kevlarai.com → platform super-admin (admin_profiles)
|
|
||||||
- KevlarAI school → already exists; adds 3 student users
|
|
||||||
- Greenfield Academy → new second school with full staff + students
|
|
||||||
|
|
||||||
Run inside ccapi container:
|
|
||||||
python3 main.py --mode seed-test
|
|
||||||
|
|
||||||
Or directly:
|
|
||||||
cd ~/api && python3 -c "
|
|
||||||
from run.initialization.seed_test_environment import seed_test_environment
|
|
||||||
import json; print(json.dumps(seed_test_environment(), indent=2))
|
|
||||||
"
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import requests
|
|
||||||
import uuid
|
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from modules.logger_tool import initialise_logger
|
|
||||||
|
|
||||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
|
||||||
|
|
||||||
# ─── Existing KevlarAI school ────────────────────────────────────────────────
|
|
||||||
KEVLARAI_INSTITUTE_ID = "6585bf91-6ae8-4d72-ab54-cddf3ba4e648"
|
|
||||||
KEVLARAI_INSTITUTE_DB = "cc.institutes.6585bf916ae84d72ab54cddf3ba4e648"
|
|
||||||
|
|
||||||
# ─── Second test school ──────────────────────────────────────────────────────
|
|
||||||
GREENFIELD_INSTITUTE_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" # deterministic UUID
|
|
||||||
GREENFIELD_URN = "TEST-GFA-001"
|
|
||||||
GREENFIELD_NAME = "Greenfield Academy"
|
|
||||||
|
|
||||||
# ─── User definitions ────────────────────────────────────────────────────────
|
|
||||||
# Format: email, password, username, full_name, display_name, user_type, role, institute_id
|
|
||||||
TEST_USERS: List[Dict] = [
|
|
||||||
# ── KevlarAI students ────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
"email": "student1@kevlarai.com",
|
|
||||||
"password": "Student1@KevlarAI!",
|
|
||||||
"username": "student1.kevlarai",
|
|
||||||
"full_name": "Alice Nguyen",
|
|
||||||
"display_name": "Alice",
|
|
||||||
"user_type": "student",
|
|
||||||
"role": "student",
|
|
||||||
"institute_id": KEVLARAI_INSTITUTE_ID,
|
|
||||||
"institute_db": KEVLARAI_INSTITUTE_DB,
|
|
||||||
"metadata": {"year_group": "Year 10"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"email": "student2@kevlarai.com",
|
|
||||||
"password": "Student2@KevlarAI!",
|
|
||||||
"username": "student2.kevlarai",
|
|
||||||
"full_name": "Ben Okafor",
|
|
||||||
"display_name": "Ben",
|
|
||||||
"user_type": "student",
|
|
||||||
"role": "student",
|
|
||||||
"institute_id": KEVLARAI_INSTITUTE_ID,
|
|
||||||
"institute_db": KEVLARAI_INSTITUTE_DB,
|
|
||||||
"metadata": {"year_group": "Year 10"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"email": "student3@kevlarai.com",
|
|
||||||
"password": "Student3@KevlarAI!",
|
|
||||||
"username": "student3.kevlarai",
|
|
||||||
"full_name": "Chloe Park",
|
|
||||||
"display_name": "Chloe",
|
|
||||||
"user_type": "student",
|
|
||||||
"role": "student",
|
|
||||||
"institute_id": KEVLARAI_INSTITUTE_ID,
|
|
||||||
"institute_db": KEVLARAI_INSTITUTE_DB,
|
|
||||||
"metadata": {"year_group": "Year 11"},
|
|
||||||
},
|
|
||||||
# ── Greenfield Academy admin ─────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
"email": "head@greenfieldacademy.test",
|
|
||||||
"password": "Admin@Greenfield1!",
|
|
||||||
"username": "head.greenfield",
|
|
||||||
"full_name": "Dr James Whitmore",
|
|
||||||
"display_name": "Dr Whitmore",
|
|
||||||
"user_type": "teacher",
|
|
||||||
"role": "school_admin",
|
|
||||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"institute_db": None, # populated after school provisioning
|
|
||||||
"metadata": {},
|
|
||||||
},
|
|
||||||
# ── Greenfield teachers ──────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
"email": "physics@greenfieldacademy.test",
|
|
||||||
"password": "Teacher1@Greenfield1!",
|
|
||||||
"username": "physics.greenfield",
|
|
||||||
"full_name": "Priya Sharma",
|
|
||||||
"display_name": "Priya",
|
|
||||||
"user_type": "teacher",
|
|
||||||
"role": "teacher",
|
|
||||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"institute_db": None,
|
|
||||||
"metadata": {"subject": "Physics"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"email": "english@greenfieldacademy.test",
|
|
||||||
"password": "Teacher2@Greenfield1!",
|
|
||||||
"username": "english.greenfield",
|
|
||||||
"full_name": "Tom Bradley",
|
|
||||||
"display_name": "Tom",
|
|
||||||
"user_type": "teacher",
|
|
||||||
"role": "teacher",
|
|
||||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"institute_db": None,
|
|
||||||
"metadata": {"subject": "English"},
|
|
||||||
},
|
|
||||||
# ── Greenfield students ──────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
"email": "alice@greenfieldacademy.test",
|
|
||||||
"password": "Student1@Greenfield1!",
|
|
||||||
"username": "alice.greenfield",
|
|
||||||
"full_name": "Alice Thornton",
|
|
||||||
"display_name": "Alice T",
|
|
||||||
"user_type": "student",
|
|
||||||
"role": "student",
|
|
||||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"institute_db": None,
|
|
||||||
"metadata": {"year_group": "Year 9"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"email": "bob@greenfieldacademy.test",
|
|
||||||
"password": "Student2@Greenfield1!",
|
|
||||||
"username": "bob.greenfield",
|
|
||||||
"full_name": "Bob Ivanov",
|
|
||||||
"display_name": "Bob",
|
|
||||||
"user_type": "student",
|
|
||||||
"role": "student",
|
|
||||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"institute_db": None,
|
|
||||||
"metadata": {"year_group": "Year 9"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"email": "carol@greenfieldacademy.test",
|
|
||||||
"password": "Student3@Greenfield1!",
|
|
||||||
"username": "carol.greenfield",
|
|
||||||
"full_name": "Carol Mensah",
|
|
||||||
"display_name": "Carol",
|
|
||||||
"user_type": "student",
|
|
||||||
"role": "student",
|
|
||||||
"institute_id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"institute_db": None,
|
|
||||||
"metadata": {"year_group": "Year 10"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def seed_test_environment() -> Dict[str, Any]:
|
|
||||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
|
||||||
from modules.database.services.provisioning_service import ProvisioningService
|
|
||||||
|
|
||||||
sb_client = SupabaseServiceRoleClient()
|
|
||||||
supabase_url = os.environ["SUPABASE_URL"]
|
|
||||||
service_key = os.environ["SERVICE_ROLE_KEY"]
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"apikey": service_key,
|
|
||||||
"Authorization": f"Bearer {service_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def auth_get(path, params=None):
|
|
||||||
r = requests.get(f"{supabase_url}/auth/v1/admin{path}", headers=headers, params=params)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def auth_post(path, data):
|
|
||||||
r = requests.post(f"{supabase_url}/auth/v1/admin{path}", headers=headers, json=data)
|
|
||||||
return r
|
|
||||||
|
|
||||||
def sb_upsert(table, data, on_conflict):
|
|
||||||
h = {**headers, "Prefer": "resolution=merge-duplicates,return=representation"}
|
|
||||||
r = requests.post(
|
|
||||||
f"{supabase_url}/rest/v1/{table}",
|
|
||||||
headers=h,
|
|
||||||
json=data,
|
|
||||||
params={"on_conflict": on_conflict},
|
|
||||||
)
|
|
||||||
return r
|
|
||||||
|
|
||||||
def sb_select(table, eq_col, eq_val):
|
|
||||||
r = requests.get(
|
|
||||||
f"{supabase_url}/rest/v1/{table}",
|
|
||||||
headers=headers,
|
|
||||||
params={"select": "*", eq_col: f"eq.{eq_val}"},
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
errors: List[str] = []
|
|
||||||
results: Dict[str, Any] = {"steps": {}}
|
|
||||||
|
|
||||||
# ── Step 1: Ensure kcar is a platform super-admin ─────────────────────────
|
|
||||||
logger.info("Step 1: Platform super-admin setup...")
|
|
||||||
try:
|
|
||||||
kcar_id = "d9e1d1a9-04c4-4611-bb05-57babf4a9a28" # known from profiles
|
|
||||||
r = sb_upsert("admin_profiles", {
|
|
||||||
"id": kcar_id,
|
|
||||||
"email": "kcar@kevlarai.com",
|
|
||||||
"display_name": "Kevin Carroll",
|
|
||||||
"admin_role": "super_admin",
|
|
||||||
"is_super_admin": True,
|
|
||||||
"metadata": {"seeded": True},
|
|
||||||
}, on_conflict="id")
|
|
||||||
if r.status_code in (200, 201):
|
|
||||||
logger.info(" kcar → admin_profiles super_admin ✓")
|
|
||||||
results["steps"]["super_admin"] = "ok"
|
|
||||||
else:
|
|
||||||
raise Exception(f"Upsert failed: {r.text[:200]}")
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"super_admin setup: {e}"
|
|
||||||
logger.error(f" {msg}")
|
|
||||||
errors.append(msg)
|
|
||||||
results["steps"]["super_admin"] = "error"
|
|
||||||
|
|
||||||
# ── Step 2: Provision Greenfield Academy ──────────────────────────────────
|
|
||||||
logger.info("Step 2: Greenfield Academy school provisioning...")
|
|
||||||
greenfield_db = None
|
|
||||||
try:
|
|
||||||
# Check if already exists
|
|
||||||
existing = sb_select("institutes", "id", GREENFIELD_INSTITUTE_ID)
|
|
||||||
if not existing:
|
|
||||||
# Determine neo4j_uuid_string (same sanitization as provisioning_service)
|
|
||||||
neo4j_uuid = GREENFIELD_INSTITUTE_ID.replace("-", "")
|
|
||||||
r = sb_upsert("institutes", {
|
|
||||||
"id": GREENFIELD_INSTITUTE_ID,
|
|
||||||
"name": GREENFIELD_NAME,
|
|
||||||
"urn": GREENFIELD_URN,
|
|
||||||
"status": "active",
|
|
||||||
"address": {"line1": "1 Academy Road", "city": "Testville", "postcode": "TE1 1ST"},
|
|
||||||
"website": "https://greenfieldacademy.test",
|
|
||||||
"metadata": {"headteacher": "Dr James Whitmore", "seeded": True},
|
|
||||||
"neo4j_uuid_string": neo4j_uuid,
|
|
||||||
}, on_conflict="id")
|
|
||||||
if r.status_code not in (200, 201):
|
|
||||||
raise Exception(f"Institute upsert: {r.text[:200]}")
|
|
||||||
logger.info(f" Greenfield Academy created [{GREENFIELD_INSTITUTE_ID[:8]}]")
|
|
||||||
|
|
||||||
# Provision Neo4j DB
|
|
||||||
provisioner = ProvisioningService()
|
|
||||||
prov_result = provisioner.ensure_school(GREENFIELD_INSTITUTE_ID)
|
|
||||||
greenfield_db = prov_result.get("db_name")
|
|
||||||
logger.info(f" Neo4j DB provisioned: {greenfield_db}")
|
|
||||||
else:
|
|
||||||
neo4j_uuid = existing[0].get("neo4j_uuid_string") or GREENFIELD_INSTITUTE_ID.replace("-", "")
|
|
||||||
greenfield_db = f"cc.institutes.{neo4j_uuid}"
|
|
||||||
logger.info(f" Greenfield Academy already exists → {greenfield_db}")
|
|
||||||
|
|
||||||
results["steps"]["greenfield_school"] = greenfield_db
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"greenfield_school: {e}"
|
|
||||||
logger.error(f" {msg}")
|
|
||||||
errors.append(msg)
|
|
||||||
results["steps"]["greenfield_school"] = "error"
|
|
||||||
greenfield_db = f"cc.institutes.{GREENFIELD_INSTITUTE_ID.replace('-', '')}"
|
|
||||||
|
|
||||||
# Update institute_db for Greenfield users
|
|
||||||
for u in TEST_USERS:
|
|
||||||
if u["institute_id"] == GREENFIELD_INSTITUTE_ID:
|
|
||||||
u["institute_db"] = greenfield_db
|
|
||||||
|
|
||||||
# ── Step 3: Create / verify all test users ────────────────────────────────
|
|
||||||
logger.info("Step 3: Creating test users...")
|
|
||||||
created_users: Dict[str, Dict] = {}
|
|
||||||
try:
|
|
||||||
all_users = auth_get("/users", params={"per_page": 200}).get("users", [])
|
|
||||||
existing_by_email = {u["email"]: u for u in all_users}
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"auth/users list: {e}"
|
|
||||||
logger.error(msg)
|
|
||||||
errors.append(msg)
|
|
||||||
existing_by_email = {}
|
|
||||||
|
|
||||||
for spec in TEST_USERS:
|
|
||||||
email = spec["email"]
|
|
||||||
if email in existing_by_email:
|
|
||||||
uid = existing_by_email[email]["id"]
|
|
||||||
logger.info(f" {email}: exists [{uid[:8]}]")
|
|
||||||
created_users[email] = {"id": uid, **spec}
|
|
||||||
continue
|
|
||||||
|
|
||||||
r = auth_post("/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"]
|
|
||||||
logger.info(f" {email}: created [{uid[:8]}]")
|
|
||||||
created_users[email] = {"id": uid, **spec}
|
|
||||||
else:
|
|
||||||
msg = f"create {email}: {r.text[:200]}"
|
|
||||||
logger.error(f" {msg}")
|
|
||||||
errors.append(msg)
|
|
||||||
time.sleep(0.25)
|
|
||||||
|
|
||||||
results["steps"]["users_created"] = list(created_users.keys())
|
|
||||||
|
|
||||||
# ── Step 4: Upsert profiles + memberships ─────────────────────────────────
|
|
||||||
logger.info("Step 4: Upserting profiles and memberships...")
|
|
||||||
for spec in TEST_USERS:
|
|
||||||
u = created_users.get(spec["email"])
|
|
||||||
if not u:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
sb_upsert("profiles", {
|
|
||||||
"id": u["id"],
|
|
||||||
"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")
|
|
||||||
|
|
||||||
sb_upsert("institute_memberships", {
|
|
||||||
"profile_id": u["id"],
|
|
||||||
"institute_id": spec["institute_id"],
|
|
||||||
"role": spec["role"],
|
|
||||||
"metadata": spec.get("metadata", {}),
|
|
||||||
}, on_conflict="profile_id,institute_id")
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"profile/membership {spec['email']}: {e}"
|
|
||||||
logger.error(f" {msg}")
|
|
||||||
errors.append(msg)
|
|
||||||
|
|
||||||
results["steps"]["profiles_memberships"] = "ok"
|
|
||||||
|
|
||||||
# ── Step 5: Neo4j Teacher/Student nodes for all users ────────────────────
|
|
||||||
logger.info("Step 5: Creating Neo4j worker nodes...")
|
|
||||||
try:
|
|
||||||
from neo4j import GraphDatabase
|
|
||||||
driver = GraphDatabase.driver("bolt://192.168.0.209:7687", auth=("neo4j", "&%N304j&%"))
|
|
||||||
|
|
||||||
# Group users by institute DB
|
|
||||||
by_db: Dict[str, List[Dict]] = {}
|
|
||||||
for spec in TEST_USERS:
|
|
||||||
u = created_users.get(spec["email"])
|
|
||||||
if not u or not spec.get("institute_db"):
|
|
||||||
continue
|
|
||||||
by_db.setdefault(spec["institute_db"], []).append({**spec, "uid": u["id"]})
|
|
||||||
|
|
||||||
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.unique_id = $uid, "
|
|
||||||
" n.user_type = $user_type, "
|
|
||||||
" n.worker_type = $user_type",
|
|
||||||
uid=u["uid"], email=u["email"],
|
|
||||||
name=u["full_name"], user_type=u["user_type"],
|
|
||||||
)
|
|
||||||
logger.info(f" [{db[:30]}] {label}: {u['email']}")
|
|
||||||
|
|
||||||
driver.close()
|
|
||||||
results["steps"]["neo4j_nodes"] = "ok"
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"neo4j_nodes: {e}"
|
|
||||||
logger.error(f" {msg}")
|
|
||||||
errors.append(msg)
|
|
||||||
results["steps"]["neo4j_nodes"] = "error"
|
|
||||||
|
|
||||||
# ── Summary ───────────────────────────────────────────────────────────────
|
|
||||||
results["success"] = len(errors) == 0
|
|
||||||
results["errors"] = errors
|
|
||||||
results["message"] = (
|
|
||||||
f"Seed complete — {len(created_users)} users across 2 schools"
|
|
||||||
if not errors
|
|
||||||
else f"{len(errors)} error(s): {errors[0]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Print credential sheet
|
|
||||||
logger.info("\n" + "=" * 60)
|
|
||||||
logger.info("TEST CREDENTIAL SHEET")
|
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info(f"{'ROLE':<20} {'EMAIL':<40} {'PASSWORD'}")
|
|
||||||
logger.info("-" * 90)
|
|
||||||
logger.info(f"{'[PLATFORM ADMIN]':<20} {'kcar@kevlarai.com':<40} KevlarAI2025!")
|
|
||||||
logger.info("-" * 90)
|
|
||||||
logger.info(f"[KevlarAI School]")
|
|
||||||
for spec in TEST_USERS:
|
|
||||||
if spec["institute_id"] == KEVLARAI_INSTITUTE_ID:
|
|
||||||
logger.info(f" {spec['role']:<18} {spec['email']:<40} {spec['password']}")
|
|
||||||
logger.info("-" * 90)
|
|
||||||
logger.info(f"[Greenfield Academy]")
|
|
||||||
for spec in TEST_USERS:
|
|
||||||
if spec["institute_id"] == GREENFIELD_INSTITUTE_ID:
|
|
||||||
logger.info(f" {spec['role']:<18} {spec['email']:<40} {spec['password']}")
|
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
return results
|
|
||||||
@ -13,11 +13,6 @@ 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.tools.lesson_plans_router import router as lesson_plans_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
|
||||||
@ -68,11 +63,6 @@ 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(lesson_plans_router, prefix="/lessons", tags=["Lesson Plans"])
|
|
||||||
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