fix(exam): block destructive template PUT once marks recorded (review #1)

PUT full-replace deletes exam_questions, and mark_entries.question_id cascades
ON DELETE — so re-saving the setup canvas after marking began would silently
wipe recorded marks. Guard: 409 if any mark_entry exists for the template's
batches. Mark-scheme edits (PATCH /questions/{id}) are unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
CC Worker
2026-06-06 19:15:35 +00:00
co-authored by Claude Opus 4.8
parent 77bb0766ff
commit e269e67f27
2 changed files with 57 additions and 0 deletions
+26
View File
@@ -61,6 +61,20 @@ def _require_owner(ctx: ExamContext, template: Dict[str, Any]) -> None:
raise HTTPException(status_code=403, detail="Only the template owner can modify it")
def _template_has_recorded_marks(ctx: ExamContext, template_id: str) -> bool:
"""True if any mark_entry exists for a batch of this template (→ destructive PUT is unsafe)."""
batches = _rows(
ctx.supabase.table("marking_batches").select("id").eq("template_id", template_id).execute()
)
batch_ids = [b["id"] for b in batches]
if not batch_ids:
return False
marks = _rows(
ctx.supabase.table("mark_entries").select("id").in_("batch_id", batch_ids).limit(1).execute()
)
return bool(marks)
# ─── templates ───────────────────────────────────────────────────────────────
@router.post("/templates")
@@ -150,6 +164,18 @@ async def replace_template(
template = _fetch_template_or_404(ctx, template_id)
_require_owner(ctx, template)
# Data-loss guard: the wholesale question delete below cascades to mark_entries
# (mark_entries.question_id → exam_questions ON DELETE CASCADE). Refuse a structural
# full-replace once any marks have been recorded against this template's batches, so
# re-saving the setup canvas mid-marking can't silently wipe a teacher's marking work.
# (Mark-scheme tweaks use PATCH /questions/{id}, which is unaffected.)
if _template_has_recorded_marks(ctx, template_id):
raise HTTPException(
status_code=409,
detail="Template has recorded marks; structural full-replace is blocked. "
"Edit questions individually via PATCH /questions/{id}.",
)
# Optional template-level metadata update alongside the canvas.
if body.meta:
updates = {k: v for k, v in body.meta.dict().items() if v is not None}