S4-5: new routers/exam/ package mounted at /api/exam (R5.1/E5, not under
/database/). Template CRUD with hybrid persistence (R5.2):
- POST/GET/GET{id}/PUT{id}/DELETE{id} /templates + PATCH /questions/{qid}
- Calls Supabase AS THE USER via SupabaseAnonClient.for_user (E1 fix), so the
RLS in 72-exam-marker.sql is enforced; no service-role for user-facing ops.
- Institute resolved/validated via the user_institute_ids() SECURITY DEFINER
RPC (institute_memberships is deny-all as-user per E4); client-supplied
institute_id is validated, never trusted (R5.5).
- Ownership pre-checked before writes (E2); out-of-scope ids read back as 404
under RLS (IDOR-safe). Soft-delete archives, never hard-deletes.
- PUT full-replace preserves client UUIDs as Neo4j join keys (spec §2).
- eb_exams.exam_code denormalised via a documented service-role catalogue
lookup (eb_exams is shared reference data, deny-all as-user per E4).
Unit tests cover auth, CRUD, ownership/IDOR, institute validation, soft-delete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
105 lines
4.3 KiB
Python
105 lines
4.3 KiB
Python
"""Pydantic request/response models for the /api/exam/ router (S4-5).
|
|
|
|
Templates are saved from the canvas with a full-replace PUT (R5.2): the client owns
|
|
stable UUIDs for questions / response areas / boundaries so the Supabase ids line up
|
|
with the Neo4j join keys (exam_questions.id ↔ Question|Part.uuid_string,
|
|
exam_response_areas.id ↔ Region.uuid_string — see spec §2). Granular mark-scheme edits
|
|
go through PATCH /api/exam/questions/{qid}.
|
|
|
|
Models mirror the columns in volumes/db/cc/72-exam-marker.sql. They are intentionally
|
|
permissive (most fields optional) so the canvas can round-trip partial state during
|
|
authoring without the API rejecting work-in-progress.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
# ─── Templates ─────────────────────────────────────────────────────────────────
|
|
|
|
class CreateTemplateRequest(BaseModel):
|
|
title: str
|
|
subject: Optional[str] = None
|
|
# Catalogue paper (eb_exams) the template maps, when chosen from the catalogue (R2.2).
|
|
exam_id: Optional[str] = None
|
|
# Denormalised onto the template for the Neo4j join (eb_exams.exam_code ↔ ExamPaper.exam_code).
|
|
# If exam_id is given but exam_code is omitted, the API resolves it from the catalogue.
|
|
exam_code: Optional[str] = None
|
|
# Uploaded PDF (files.id) for an ad-hoc paper (R2.2).
|
|
source_file_id: Optional[str] = None
|
|
page_count: Optional[int] = None
|
|
# Active institute (R1.4/R5.5). Validated against the caller's memberships; never trusted
|
|
# as the authorization signal. Optional when the caller belongs to exactly one institute.
|
|
institute_id: Optional[str] = None
|
|
|
|
|
|
class UpdateTemplateMetaRequest(BaseModel):
|
|
"""Template-level fields that a full-replace PUT may also update alongside the canvas."""
|
|
title: Optional[str] = None
|
|
subject: Optional[str] = None
|
|
page_count: Optional[int] = None
|
|
status: Optional[Literal["draft", "ready", "archived"]] = None
|
|
|
|
|
|
# ─── Canvas entities (children of a template) ────────────────────────────────────
|
|
|
|
class QuestionPayload(BaseModel):
|
|
# Client-supplied stable UUID (== Neo4j Question|Part.uuid_string). Optional on first save.
|
|
id: Optional[str] = None
|
|
parent_id: Optional[str] = None
|
|
label: str
|
|
order: int = 0
|
|
max_marks: float = 0
|
|
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
|
mcq_options: Optional[Any] = None
|
|
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
|
|
is_container: bool = False
|
|
spec_ref: Optional[str] = None
|
|
|
|
|
|
class ResponseAreaPayload(BaseModel):
|
|
id: Optional[str] = None # == Neo4j Region.uuid_string
|
|
question_id: str
|
|
page: int
|
|
bounds: Dict[str, Any] # {x,y,w,h}
|
|
kind: Literal["response", "context"]
|
|
response_form: Optional[
|
|
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
|
|
] = None
|
|
source: Literal["manual", "ai"] = "manual"
|
|
confirmed: bool = True
|
|
confidence: Optional[float] = None
|
|
|
|
|
|
class BoundaryPayload(BaseModel):
|
|
id: Optional[str] = None
|
|
question_id: Optional[str] = None
|
|
label: Optional[str] = None
|
|
page_index: int
|
|
y: float
|
|
bounds: Optional[Dict[str, Any]] = None
|
|
source: Literal["manual", "ai"] = "manual"
|
|
confirmed: bool = True
|
|
|
|
|
|
class TemplateReplaceRequest(BaseModel):
|
|
"""Full-replace canvas save (R5.2 primary path). All children are replaced wholesale."""
|
|
meta: Optional[UpdateTemplateMetaRequest] = None
|
|
questions: List[QuestionPayload] = Field(default_factory=list)
|
|
response_areas: List[ResponseAreaPayload] = Field(default_factory=list)
|
|
boundaries: List[BoundaryPayload] = Field(default_factory=list)
|
|
|
|
|
|
class PatchQuestionRequest(BaseModel):
|
|
"""Incremental mark-scheme / spec-ref edit (R5.2 granular path)."""
|
|
label: Optional[str] = None
|
|
order: Optional[int] = None
|
|
max_marks: Optional[float] = None
|
|
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
|
mcq_options: Optional[Any] = None
|
|
mark_scheme: Optional[Dict[str, Any]] = None
|
|
is_container: Optional[bool] = None
|
|
spec_ref: Optional[str] = None
|