Adds routers/exam/batches.py (mounted alongside templates under /api/exam):
- POST/GET /batches — batch creation seeds the cohort from class_students AS
THE USER (cs_read requires caller teaches/admins the class); each active
enrollee becomes a student_submissions row (status='absent') so no student
is ever dropped from results (A7). Display names denormalised via a
documented service-role profiles read (deny-all as-user, E4).
- GET /batches/{id}/queue — submissions + per-submission mark counts + progress.
- GET /batches/{id}/results + /csv — every roster student incl. absent (blank
marks/total); CSV row always present (A7 baked into the contract).
- PUT /marks/{id} — upsert; batch_id derived server-side from the submission
(client never supplies the RLS scoping key).
- POST /batches/{id}/scans — E3 guards: MIME check, hard size ceiling (chunked
read), %PDF magic-byte sniff; owner-only; stores via service-role storage;
manual/ordered matching (QR-decode is a follow-on, no QR fixtures yet).
Unit tests cover batch/roster-seed/list, queue, results+CSV A7, mark upsert
round-trip, and all scan guards + owner check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
6.3 KiB
Python
147 lines
6.3 KiB
Python
"""Auth + data-access plumbing for the /api/exam/ router.
|
|
|
|
Per the audit (spec S1/E1): the exam API calls Supabase **as the user** so the RLS in
|
|
72-exam-marker.sql is actually enforced — it does NOT use the service role for user-facing
|
|
reads/writes the way files.py / classes_router.py do. The bearer already attaches the raw
|
|
JWT as payload["_access_token"] (supabase_bearer.py) precisely for this.
|
|
|
|
Institute resolution is the one wrinkle: institute_memberships and profiles are RLS
|
|
deny-all to a normal authenticated user (E4), so we cannot read them as-user. Instead we
|
|
call public.user_institute_ids() — a SECURITY DEFINER function (71-class-management.sql) that
|
|
PostgREST exposes as an RPC — which returns the caller's institute ids regardless of those
|
|
table policies. This is the same function the RLS policies themselves key off, so the API's
|
|
view of "which institutes is this user in" is guaranteed consistent with what RLS will allow.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import Depends, HTTPException
|
|
|
|
from modules.auth.supabase_bearer import SupabaseBearer
|
|
from modules.database.supabase.utils.client import (
|
|
SupabaseAnonClient,
|
|
SupabaseServiceRoleClient,
|
|
)
|
|
from modules.logger_tool import initialise_logger
|
|
|
|
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
|
|
|
auth = SupabaseBearer()
|
|
|
|
|
|
class ExamContext:
|
|
"""The per-request handle every exam endpoint works through.
|
|
|
|
Bundles the caller's id, an as-user Supabase client (RLS-enforced), and the set of
|
|
institute ids the caller belongs to (for R5.5 institute validation on writes).
|
|
"""
|
|
|
|
def __init__(self, user_id: str, access_token: str, supabase: Any, institute_ids: List[str]):
|
|
self.user_id = user_id
|
|
self.access_token = access_token
|
|
self.supabase = supabase
|
|
self.institute_ids = institute_ids
|
|
|
|
def resolve_institute(self, requested: Optional[str]) -> str:
|
|
"""Validate a client-supplied institute_id, or pick the sole membership.
|
|
|
|
R5.5: a client-supplied institute_id is never trusted as the authz signal — it must
|
|
be one the caller actually belongs to. RLS would reject a bad value at write time
|
|
anyway; resolving here turns that into a clean 400/403 instead of an opaque DB error.
|
|
"""
|
|
if requested:
|
|
if requested not in self.institute_ids:
|
|
raise HTTPException(status_code=403, detail="Not a member of the requested institute")
|
|
return requested
|
|
if len(self.institute_ids) == 1:
|
|
return self.institute_ids[0]
|
|
if not self.institute_ids:
|
|
raise HTTPException(status_code=403, detail="Caller has no institute membership")
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="institute_id is required when the caller belongs to multiple institutes",
|
|
)
|
|
|
|
|
|
def _extract_institute_ids(rpc_data: Any) -> List[str]:
|
|
"""Normalise the user_institute_ids() RPC result to a list of uuid strings.
|
|
|
|
A `returns setof uuid` function comes back from PostgREST as a JSON array of scalars,
|
|
but tolerate the `[{"user_institute_ids": "..."}]` shape too in case of driver quirks.
|
|
"""
|
|
out: List[str] = []
|
|
for row in rpc_data or []:
|
|
if isinstance(row, dict):
|
|
val = row.get("user_institute_ids") or next(iter(row.values()), None)
|
|
else:
|
|
val = row
|
|
if val:
|
|
out.append(str(val))
|
|
return out
|
|
|
|
|
|
async def get_exam_context(payload: Dict[str, Any] = Depends(auth)) -> ExamContext:
|
|
user_id = payload.get("sub") or payload.get("user_id")
|
|
access_token = payload.get("_access_token")
|
|
if not user_id or not access_token:
|
|
raise HTTPException(status_code=401, detail="Invalid token payload")
|
|
|
|
supabase = SupabaseAnonClient.for_user(access_token).supabase
|
|
|
|
try:
|
|
res = supabase.rpc("user_institute_ids").execute()
|
|
institute_ids = _extract_institute_ids(getattr(res, "data", None))
|
|
except Exception as exc:
|
|
logger.error(f"Failed to resolve institute memberships: {exc}")
|
|
raise HTTPException(status_code=502, detail="Could not resolve institute membership")
|
|
|
|
return ExamContext(user_id, access_token, supabase, institute_ids)
|
|
|
|
|
|
def resolve_student_names(student_ids: List[str]) -> Dict[str, str]:
|
|
"""Map profile id → display name for roster students (batch-creation denormalisation).
|
|
|
|
Documented service-role exception (S1, mirrors lookup_exam_code): `profiles` has no as-user
|
|
SELECT policy (E4), so the roster's display names can't be read as-the-user. The caller's
|
|
right to the roster itself is already enforced as-user (class_students.cs_read requires the
|
|
caller to teach/admin the class); this only resolves names for ids already authorised, and
|
|
the result is denormalised onto student_submissions so later reads need no profiles access.
|
|
"""
|
|
if not student_ids:
|
|
return {}
|
|
try:
|
|
sb = SupabaseServiceRoleClient().supabase
|
|
res = (
|
|
sb.table("profiles")
|
|
.select("id, full_name, display_name, email")
|
|
.in_("id", list(student_ids))
|
|
.execute()
|
|
)
|
|
out: Dict[str, str] = {}
|
|
for p in getattr(res, "data", None) or []:
|
|
out[p["id"]] = p.get("full_name") or p.get("display_name") or p.get("email") or ""
|
|
return out
|
|
except Exception as exc:
|
|
logger.warning(f"student name resolution failed: {exc}")
|
|
return {}
|
|
|
|
|
|
def lookup_exam_code(exam_id: str) -> Optional[str]:
|
|
"""Resolve eb_exams.exam_code for a catalogue paper (denormalised onto the template).
|
|
|
|
Documented service-role exception (S1): eb_exams is shared exam-board reference data with
|
|
no as-user SELECT policy (E4), so a normal user cannot read it. This is a read of public
|
|
catalogue metadata only — not user-scoped data — and is used solely to keep the Neo4j join
|
|
key (exam_code) correct on the template row.
|
|
"""
|
|
try:
|
|
sb = SupabaseServiceRoleClient().supabase
|
|
res = sb.table("eb_exams").select("exam_code").eq("id", exam_id).limit(1).execute()
|
|
rows = getattr(res, "data", None) or []
|
|
return rows[0].get("exam_code") if rows else None
|
|
except Exception as exc:
|
|
logger.warning(f"exam_code lookup failed for exam_id={exam_id}: {exc}")
|
|
return None
|