feat(exam): /api/exam template CRUD router (as-user RLS, E1 fix)
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6ce6272a1e
commit
f52c3267ca
@@ -0,0 +1,104 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user