feat(phase-b): Supabase-first timetable, classes, enrollment, and student views
- timetable_builder_router: Supabase-primary slot write (POST /timetable/slots),
week_cycle support, GET /slots reads from Supabase, materialize-periods endpoint,
rebuild-neo4j endpoint, sync-lessons endpoint (Track B: TaughtLesson Neo4j nodes),
_sync_teacher_timetables_to_neo4j and _sync_taught_lessons_to_neo4j helpers
- classes_router: GET /{class_id} enriched with profiles + enrollment_requests,
GET /school/students for admin search, PATCH /enrollment-requests/{id} approve/reject
- taught_lessons_router: GET /student/lessons student week view with enrichment
- school_router: academic_periods sync, day-type management
- platform_admin_router + platform_admin: POST /admin/reset and /admin/seed endpoints
- invitations_router: teacher invite scaffolding
- reset_environment + seed_environment: idempotent dev environment scripts
- graph_tree_router: Supabase-first institute resolution
- provisioning_service: neo4j_private_db_name column support
- main.py + run/routers.py: register new routers
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Invitations & People Router.
|
||||
|
||||
POST /users/invite — school admin sends magic-link invitation
|
||||
GET /users/invitations — list invitations for the school
|
||||
DELETE /users/invitations/{id} — cancel a pending invitation
|
||||
POST /users/invitations/{id}/resend — re-send magic link
|
||||
GET /users/staff — list current staff members
|
||||
GET /users/students — list current student members
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
VALID_ROLES = {"teacher", "student", "school_admin", "department_head"}
|
||||
STAFF_ROLES = {"teacher", "school_admin", "department_head"}
|
||||
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||
try:
|
||||
sb = _sb()
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
return str((p.data or {}).get("school_id") or "") or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _require_institute(user_id: str) -> str:
|
||||
iid = _resolve_institute_id(user_id)
|
||||
if not iid:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
return iid
|
||||
|
||||
|
||||
def _require_school_admin(user_id: str, institute_id: str) -> None:
|
||||
try:
|
||||
sb = _sb()
|
||||
m = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("role")
|
||||
.eq("profile_id", user_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
role = (m.data or {}).get("role", "")
|
||||
if role not in ("school_admin", "department_head"):
|
||||
raise HTTPException(status_code=403, detail="School admin access required")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(status_code=403, detail="Could not verify school admin status")
|
||||
|
||||
|
||||
# ─── Request models ───────────────────────────────────────────────────────────
|
||||
|
||||
class InviteRequest(BaseModel):
|
||||
email: str
|
||||
role: str # teacher | student | school_admin | department_head
|
||||
metadata: Optional[Dict[str, Any]] = None # year_group, subject, department, etc.
|
||||
|
||||
|
||||
# ─── Invite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/invite")
|
||||
async def invite_user(
|
||||
body: InviteRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an invitation row and send a Supabase magic-link invite email.
|
||||
Idempotent: if a pending invitation already exists for the same email/school,
|
||||
it is returned (use /resend to refresh the magic link).
|
||||
"""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
if body.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {sorted(VALID_ROLES)}")
|
||||
|
||||
email = body.email.strip().lower()
|
||||
sb = _sb()
|
||||
|
||||
# Check for existing pending invitation
|
||||
existing = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id,status,email,role,created_at,expires_at")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("email", email)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
if existing:
|
||||
return {
|
||||
"status": "already_pending",
|
||||
"invitation": existing[0],
|
||||
"message": "A pending invitation already exists. Use /resend to refresh the magic link.",
|
||||
}
|
||||
|
||||
# Insert invitation row
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||
inv_data = {
|
||||
"institute_id": institute_id,
|
||||
"email": email,
|
||||
"role": body.role,
|
||||
"invited_by": user_id,
|
||||
"expires_at": expires_at,
|
||||
"status": "pending",
|
||||
"metadata": body.metadata or {},
|
||||
}
|
||||
inv_res = sb.supabase.table("invitations").insert(inv_data).execute()
|
||||
if not inv_res.data:
|
||||
raise HTTPException(status_code=500, detail="Failed to create invitation record")
|
||||
invitation = inv_res.data[0]
|
||||
|
||||
# Send magic link via Supabase Auth admin
|
||||
try:
|
||||
sb.supabase.auth.admin.invite_user_by_email(
|
||||
email,
|
||||
options={
|
||||
"data": {
|
||||
"invitation_id": invitation["id"],
|
||||
"institute_id": institute_id,
|
||||
"role": body.role,
|
||||
**(body.metadata or {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
magic_link_sent = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Magic link send failed for {email}: {e}")
|
||||
magic_link_sent = False
|
||||
|
||||
logger.info(f"Invited {email} as {body.role} to school {institute_id} by {user_id}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"invitation": invitation,
|
||||
"magic_link_sent": magic_link_sent,
|
||||
}
|
||||
|
||||
|
||||
# ─── List invitations ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/invitations")
|
||||
async def list_invitations(
|
||||
role: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None, description="pending|accepted|expired|cancelled"),
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
q = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("id,email,role,status,invited_by,expires_at,created_at,metadata")
|
||||
.eq("institute_id", institute_id)
|
||||
.order("created_at", desc=True)
|
||||
)
|
||||
if role:
|
||||
q = q.eq("role", role)
|
||||
if status:
|
||||
q = q.eq("status", status)
|
||||
|
||||
rows = q.execute().data or []
|
||||
|
||||
# Mark expired rows (status still 'pending' but past expires_at)
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
if row["status"] == "pending":
|
||||
try:
|
||||
exp = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00"))
|
||||
if exp < now:
|
||||
row["status"] = "expired"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "invitations": rows, "total": len(rows)}
|
||||
|
||||
|
||||
# ─── Cancel invitation ────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/invitations/{invitation_id}")
|
||||
async def cancel_invitation(
|
||||
invitation_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
res = (
|
||||
sb.supabase.table("invitations")
|
||||
.update({"status": "cancelled"})
|
||||
.eq("id", invitation_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("status", "pending")
|
||||
.execute()
|
||||
)
|
||||
if not res.data:
|
||||
raise HTTPException(status_code=404, detail="Pending invitation not found")
|
||||
return {"status": "ok", "invitation": res.data[0]}
|
||||
|
||||
|
||||
# ─── Resend invitation ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/invitations/{invitation_id}/resend")
|
||||
async def resend_invitation(
|
||||
invitation_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""Re-trigger the magic link for a pending (or expired) invitation."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
inv = (
|
||||
sb.supabase.table("invitations")
|
||||
.select("*")
|
||||
.eq("id", invitation_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
).data
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
if inv["status"] == "accepted":
|
||||
raise HTTPException(status_code=400, detail="Invitation already accepted")
|
||||
if inv["status"] == "cancelled":
|
||||
raise HTTPException(status_code=400, detail="Invitation was cancelled — create a new one")
|
||||
|
||||
# Refresh expiry + status
|
||||
new_expires = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||
sb.supabase.table("invitations").update({
|
||||
"expires_at": new_expires,
|
||||
"status": "pending",
|
||||
}).eq("id", invitation_id).execute()
|
||||
|
||||
# Re-send magic link
|
||||
try:
|
||||
sb.supabase.auth.admin.invite_user_by_email(
|
||||
inv["email"],
|
||||
options={
|
||||
"data": {
|
||||
"invitation_id": invitation_id,
|
||||
"institute_id": institute_id,
|
||||
"role": inv["role"],
|
||||
**inv.get("metadata", {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
magic_link_sent = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Resend magic link failed for {inv['email']}: {e}")
|
||||
magic_link_sent = False
|
||||
|
||||
return {"status": "ok", "magic_link_sent": magic_link_sent, "new_expires_at": new_expires}
|
||||
|
||||
|
||||
# ─── Staff list ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/staff")
|
||||
async def list_staff(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""List all staff members (teachers, admins, department heads) in the school."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id,role,joined_at")
|
||||
.eq("institute_id", institute_id)
|
||||
.in_("role", list(STAFF_ROLES))
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
if not members:
|
||||
return {"status": "ok", "staff": [], "total": 0}
|
||||
|
||||
profile_ids = [m["profile_id"] for m in members]
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id,email,username,display_name")
|
||||
.in_("id", profile_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
profile_map = {p["id"]: p for p in profiles}
|
||||
|
||||
staff = []
|
||||
for m in members:
|
||||
p = profile_map.get(m["profile_id"], {})
|
||||
staff.append({
|
||||
"profile_id": m["profile_id"],
|
||||
"email": p.get("email"),
|
||||
"username": p.get("username"),
|
||||
"display_name": p.get("display_name"),
|
||||
"role": m["role"],
|
||||
"joined_at": m["joined_at"],
|
||||
})
|
||||
|
||||
return {"status": "ok", "staff": staff, "total": len(staff)}
|
||||
|
||||
|
||||
# ─── Student list ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/students")
|
||||
async def list_students(
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""List all student members in the school."""
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
_require_school_admin(user_id, institute_id)
|
||||
|
||||
sb = _sb()
|
||||
members = (
|
||||
sb.supabase.table("institute_memberships")
|
||||
.select("profile_id,role,joined_at")
|
||||
.eq("institute_id", institute_id)
|
||||
.eq("role", "student")
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
if not members:
|
||||
return {"status": "ok", "students": [], "total": 0}
|
||||
|
||||
profile_ids = [m["profile_id"] for m in members]
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id,email,username,display_name")
|
||||
.in_("id", profile_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
profile_map = {p["id"]: p for p in profiles}
|
||||
|
||||
students = []
|
||||
for m in members:
|
||||
p = profile_map.get(m["profile_id"], {})
|
||||
students.append({
|
||||
"profile_id": m["profile_id"],
|
||||
"email": p.get("email"),
|
||||
"username": p.get("username"),
|
||||
"display_name": p.get("display_name"),
|
||||
"role": m["role"],
|
||||
"joined_at": m["joined_at"],
|
||||
})
|
||||
|
||||
return {"status": "ok", "students": students, "total": len(students)}
|
||||
Reference in New Issue
Block a user