Compare commits

..
Author SHA1 Message Date
CC WorkerandClaude Sonnet 4.6 9cc986a3f1 fix(exam): allow any institute teacher to fetch template source PDF
api-ci-deploy / test-build-deploy (push) Has been cancelled
Removed the teacher_id ownership check from _require_source_visibility_or_404.
RLS already ensures a teacher can only see templates in their institute;
the ownership gate was blocking shared templates (e.g. board-uploaded AQA papers)
for any teacher who didn't personally create them.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 09:55:03 +00:00
CC Worker 6daa905ecd Merge remote-tracking branch 'origin/agent/s4-8-2-template-versioning-api'
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:12:05 +00:00
kcar 28aafaa60f feat(exam): add metadata patch for templates 2026-06-07 00:33:01 +01:00
2 changed files with 82 additions and 5 deletions
+52 -5
View File
@@ -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)
@@ -68,9 +69,7 @@ def _require_owner(ctx: ExamContext, template: Dict[str, Any]) -> None:
def _require_source_visibility_or_404(ctx: ExamContext, template: Dict[str, Any]) -> None: def _require_source_visibility_or_404(ctx: ExamContext, template: Dict[str, Any]) -> None:
"""Template source reads must not leak existence across institutes or non-owners.""" """Institute boundary check — RLS already gates template visibility; this prevents cross-institute PDF leakage."""
if template.get("teacher_id") != ctx.user_id:
raise HTTPException(status_code=404, detail="Template not found")
if template.get("institute_id") not in ctx.institute_ids: if template.get("institute_id") not in ctx.institute_ids:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail="Template not found")
@@ -89,6 +88,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 +403,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 +486,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 +551,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")
+30
View File
@@ -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)