"""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 # Drawn Part box geometry (73-exam-marker-regions.sql). Null for derived main questions. bounds: Optional[Dict[str, Any]] = None # {x,y,w,h} page: Optional[int] = None class ResponseAreaPayload(BaseModel): id: Optional[str] = None # == Neo4j Region.uuid_string (only response/context project) question_id: str page: int bounds: Dict[str, Any] # {x,y,w,h} # S4-9 taxonomy (73-exam-marker-regions.sql): response/context graded-or-stimulus; # question_number/mark_area = physical metadata; reference = student resource; furniture = ignore. kind: Literal["response", "context", "question_number", "mark_area", "reference", "furniture"] response_form: Optional[ Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"] ] = None # Optional Context differentiation (v1 generic; future graph/chart/data_table/diagram/code_block/passage). context_type: Optional[str] = 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 # ─── Marking batches & marks ───────────────────────────────────────────────── class CreateBatchRequest(BaseModel): template_id: str # When a class is given, the roster (class_students, status='active') is materialised as # student_submissions(status='absent') so every enrolled student appears in results (A7). class_id: Optional[str] = None title: Optional[str] = None class MarkUpsertRequest(BaseModel): """Upsert one mark entry (PUT /marks/{id}; id is the mark_entry uuid). batch_id is derived server-side from the submission, so the client never sets the RLS scoping key. submission_id + question_id identify what is being marked. """ submission_id: str question_id: str awarded_marks: float = 0 mark_scheme_detail: Optional[Dict[str, Any]] = None annotation_shape_ids: Optional[Any] = None comment: Optional[str] = None confirmed: Optional[bool] = None