feat(exam): add metadata patch for templates
This commit is contained in:
parent
115ecd2351
commit
28aafaa60f
@ -28,6 +28,7 @@ from routers.exam.schemas import (
|
|||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
PatchQuestionRequest,
|
PatchQuestionRequest,
|
||||||
TemplateReplaceRequest,
|
TemplateReplaceRequest,
|
||||||
|
UpdateTemplateMetaRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
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)
|
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]:
|
def _parse_storage_loc(storage_loc: str) -> Tuple[str, str]:
|
||||||
bucket, sep, path = (storage_loc or "").partition("/")
|
bucket, sep, path = (storage_loc or "").partition("/")
|
||||||
if not bucket or not sep or not path:
|
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.
|
# Optional template-level metadata update alongside the canvas.
|
||||||
if body.meta:
|
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:
|
if updates:
|
||||||
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
|
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)
|
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}")
|
@router.delete("/templates/{template_id}")
|
||||||
async def archive_template(
|
async def archive_template(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
@ -504,7 +553,7 @@ async def patch_question(
|
|||||||
body: PatchQuestionRequest,
|
body: PatchQuestionRequest,
|
||||||
ctx: ExamContext = Depends(get_exam_context),
|
ctx: ExamContext = Depends(get_exam_context),
|
||||||
) -> Dict[str, Any]:
|
) -> 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:
|
if not updates:
|
||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
|||||||
@ -359,6 +359,36 @@ def test_put_replace_denied_for_non_owner():
|
|||||||
assert resp.status_code == 403
|
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():
|
def test_archive_soft_deletes():
|
||||||
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
store = {"exam_templates": [{"id": "t1", "title": "p", "status": "draft", "institute_id": INST_A, "teacher_id": TEACHER}]}
|
||||||
client, store = make_client(store=store)
|
client, store = make_client(store=store)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user