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