Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
Backend follow-on to migration 73: - schemas: ResponseAreaPayload.kind extended to response|context|question_number| mark_area|reference|furniture + context_type; QuestionPayload gains bounds+page. - PUT serialization persists Part bounds/page and region context_type. - Neo4j projection only emits Region nodes for response/context regions; the metadata kinds (question_number/mark_area/reference/furniture) are physical-layer only and stay out of cc.public.exams. - Unit test: new kinds + Part geometry + context_type round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
5.9 KiB
Python
137 lines
5.9 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
|
|
# 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
|