From 28aafaa60f77616c529db77be7f4095a297b7f39 Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 00:33:01 +0100 Subject: [PATCH] feat(exam): add metadata patch for templates --- routers/exam/templates.py | 53 ++++++++++++++++++++++++++++++++++-- tests/test_exam_templates.py | 30 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/routers/exam/templates.py b/routers/exam/templates.py index c80909e..34f1436 100644 --- a/routers/exam/templates.py +++ b/routers/exam/templates.py @@ -28,6 +28,7 @@ from routers.exam.schemas import ( CreateTemplateRequest, PatchQuestionRequest, TemplateReplaceRequest, + UpdateTemplateMetaRequest, ) logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) @@ -89,6 +90,28 @@ def _template_has_recorded_marks(ctx: ExamContext, template_id: str) -> bool: return bool(marks) + +def _model_fields_set(model: Any) -> set[str]: + """Return fields explicitly provided by the client (Pydantic v1/v2 compatible).""" + if hasattr(model, "model_fields_set"): + return set(model.model_fields_set) + return set(getattr(model, "__fields_set__", set())) + + +def _model_dump(model: Any) -> Dict[str, Any]: + """Dump a Pydantic model without pinning this router to v1/v2 APIs.""" + if hasattr(model, "model_dump"): + return model.model_dump() + return model.dict() + + +def _template_meta_updates(body: UpdateTemplateMetaRequest, *, include_explicit_nulls: bool = False) -> Dict[str, Any]: + data = _model_dump(body) + if include_explicit_nulls: + fields = _model_fields_set(body) + return {k: data[k] for k in fields if k in data} + return {k: v for k, v in data.items() if v is not None} + def _parse_storage_loc(storage_loc: str) -> Tuple[str, str]: bucket, sep, path = (storage_loc or "").partition("/") if not bucket or not sep or not path: @@ -382,7 +405,7 @@ async def replace_template( # 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} + updates = _template_meta_updates(body.meta) if updates: ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute() @@ -465,6 +488,32 @@ async def replace_template( return await get_template(template_id, ctx) +@router.patch("/templates/{template_id}") +async def patch_template_meta( + template_id: str, + body: UpdateTemplateMetaRequest, + ctx: ExamContext = Depends(get_exam_context), +) -> Dict[str, Any]: + """Metadata-only template update. + + Unlike PUT /templates/{id}, this never deletes/re-inserts questions, response areas or + boundaries, so it is safe after marking has started. RLS scopes the initial read and + owner-only writes are enforced explicitly like the structural save path. + """ + updates = _template_meta_updates(body, include_explicit_nulls=True) + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + template = _fetch_template_or_404(ctx, template_id) + _require_owner(ctx, template) + + res = ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute() + updated = _first(res) + if not updated: + raise HTTPException(status_code=404, detail="Template not found") + return updated + + @router.delete("/templates/{template_id}") async def archive_template( template_id: str, @@ -504,7 +553,7 @@ async def patch_question( body: PatchQuestionRequest, ctx: ExamContext = Depends(get_exam_context), ) -> Dict[str, Any]: - updates = {k: v for k, v in body.dict().items() if v is not None} + updates = {k: v for k, v in _model_dump(body).items() if v is not None} if not updates: raise HTTPException(status_code=400, detail="No fields to update") diff --git a/tests/test_exam_templates.py b/tests/test_exam_templates.py index 01421c6..e6626d7 100644 --- a/tests/test_exam_templates.py +++ b/tests/test_exam_templates.py @@ -359,6 +359,36 @@ def test_put_replace_denied_for_non_owner(): assert resp.status_code == 403 +def test_patch_template_meta_does_not_replace_children_when_marks_recorded(): + store = { + "exam_templates": [{"id": "t1", "title": "old", "subject": "Physics", "page_count": 12, "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}], + "exam_questions": [{"id": "q1", "template_id": "t1", "label": "01", "order": 0}], + "marking_batches": [{"id": "b1", "template_id": "t1", "teacher_id": TEACHER, "institute_id": INST_A}], + "mark_entries": [{"id": "m1", "batch_id": "b1", "submission_id": "s1", "question_id": "q1", "awarded_marks": 2}], + } + client, store = make_client(store=store) + resp = client.patch("/api/exam/templates/t1", json={"title": "renamed", "subject": None, "page_count": 13}) + assert resp.status_code == 200 + body = resp.json() + assert body["title"] == "renamed" + assert body["subject"] is None + assert body["page_count"] == 13 + assert {q["id"] for q in store["exam_questions"]} == {"q1"} + assert {m["id"] for m in store["mark_entries"]} == {"m1"} + + +def test_patch_template_meta_denied_for_non_owner(): + store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": OTHER_TEACHER}]} + client, _ = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store) + assert client.patch("/api/exam/templates/t1", json={"title": "nope"}).status_code == 403 + + +def test_patch_template_meta_empty_body_is_400(): + store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]} + client, _ = make_client(store=store) + assert client.patch("/api/exam/templates/t1", json={}).status_code == 400 + + def test_archive_soft_deletes(): store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]} client, store = make_client(store=store)