Compare commits
No commits in common. "6daa905ecdac4d34b6211f17b840ad5fb7614331" and "115ecd2351379890caa99e3b13a64356c86395b5" have entirely different histories.
6daa905ecd
...
115ecd2351
@ -28,7 +28,6 @@ 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)
|
||||||
@ -90,28 +89,6 @@ 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:
|
||||||
@ -405,7 +382,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 = _template_meta_updates(body.meta)
|
updates = {k: v for k, v in body.meta.dict().items() if v is not None}
|
||||||
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()
|
||||||
|
|
||||||
@ -488,32 +465,6 @@ 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,
|
||||||
@ -553,7 +504,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 _model_dump(body).items() if v is not None}
|
updates = {k: v for k, v in body.dict().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,36 +359,6 @@ 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