api/routers/database/tools/invitations_router.py
kcar abf8d05ca1 feat(phase-b): Supabase-first timetable, classes, enrollment, and student views
- timetable_builder_router: Supabase-primary slot write (POST /timetable/slots),
  week_cycle support, GET /slots reads from Supabase, materialize-periods endpoint,
  rebuild-neo4j endpoint, sync-lessons endpoint (Track B: TaughtLesson Neo4j nodes),
  _sync_teacher_timetables_to_neo4j and _sync_taught_lessons_to_neo4j helpers
- classes_router: GET /{class_id} enriched with profiles + enrollment_requests,
  GET /school/students for admin search, PATCH /enrollment-requests/{id} approve/reject
- taught_lessons_router: GET /student/lessons student week view with enrichment
- school_router: academic_periods sync, day-type management
- platform_admin_router + platform_admin: POST /admin/reset and /admin/seed endpoints
- invitations_router: teacher invite scaffolding
- reset_environment + seed_environment: idempotent dev environment scripts
- graph_tree_router: Supabase-first institute resolution
- provisioning_service: neo4j_private_db_name column support
- main.py + run/routers.py: register new routers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:55:44 +01:00

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)}