feat(exam): /api/exam template CRUD router (as-user RLS, E1 fix)
S4-5: new routers/exam/ package mounted at /api/exam (R5.1/E5, not under
/database/). Template CRUD with hybrid persistence (R5.2):
- POST/GET/GET{id}/PUT{id}/DELETE{id} /templates + PATCH /questions/{qid}
- Calls Supabase AS THE USER via SupabaseAnonClient.for_user (E1 fix), so the
RLS in 72-exam-marker.sql is enforced; no service-role for user-facing ops.
- Institute resolved/validated via the user_institute_ids() SECURITY DEFINER
RPC (institute_memberships is deny-all as-user per E4); client-supplied
institute_id is validated, never trusted (R5.5).
- Ownership pre-checked before writes (E2); out-of-scope ids read back as 404
under RLS (IDOR-safe). Soft-delete archives, never hard-deletes.
- PUT full-replace preserves client UUIDs as Neo4j join keys (spec §2).
- eb_exams.exam_code denormalised via a documented service-role catalogue
lookup (eb_exams is shared reference data, deny-all as-user per E4).
Unit tests cover auth, CRUD, ownership/IDOR, institute validation, soft-delete.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6ce6272a1e
commit
f52c3267ca
@@ -0,0 +1,9 @@
|
||||
"""Exam-marker API package (/api/exam/).
|
||||
|
||||
A clean top-level router group (R5.1/E5), deliberately NOT nested under /database/. Every
|
||||
endpoint authenticates the JWT and calls Supabase as-the-user so the RLS in
|
||||
volumes/db/cc/72-exam-marker.sql is enforced (spec E1/E2 fixes).
|
||||
"""
|
||||
from routers.exam.templates import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Auth + data-access plumbing for the /api/exam/ router.
|
||||
|
||||
Per the audit (spec S1/E1): the exam API calls Supabase **as the user** so the RLS in
|
||||
72-exam-marker.sql is actually enforced — it does NOT use the service role for user-facing
|
||||
reads/writes the way files.py / classes_router.py do. The bearer already attaches the raw
|
||||
JWT as payload["_access_token"] (supabase_bearer.py) precisely for this.
|
||||
|
||||
Institute resolution is the one wrinkle: institute_memberships and profiles are RLS
|
||||
deny-all to a normal authenticated user (E4), so we cannot read them as-user. Instead we
|
||||
call public.user_institute_ids() — a SECURITY DEFINER function (71-class-management.sql) that
|
||||
PostgREST exposes as an RPC — which returns the caller's institute ids regardless of those
|
||||
table policies. This is the same function the RLS policies themselves key off, so the API's
|
||||
view of "which institutes is this user in" is guaranteed consistent with what RLS will allow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import (
|
||||
SupabaseAnonClient,
|
||||
SupabaseServiceRoleClient,
|
||||
)
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
auth = SupabaseBearer()
|
||||
|
||||
|
||||
class ExamContext:
|
||||
"""The per-request handle every exam endpoint works through.
|
||||
|
||||
Bundles the caller's id, an as-user Supabase client (RLS-enforced), and the set of
|
||||
institute ids the caller belongs to (for R5.5 institute validation on writes).
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str, access_token: str, supabase: Any, institute_ids: List[str]):
|
||||
self.user_id = user_id
|
||||
self.access_token = access_token
|
||||
self.supabase = supabase
|
||||
self.institute_ids = institute_ids
|
||||
|
||||
def resolve_institute(self, requested: Optional[str]) -> str:
|
||||
"""Validate a client-supplied institute_id, or pick the sole membership.
|
||||
|
||||
R5.5: a client-supplied institute_id is never trusted as the authz signal — it must
|
||||
be one the caller actually belongs to. RLS would reject a bad value at write time
|
||||
anyway; resolving here turns that into a clean 400/403 instead of an opaque DB error.
|
||||
"""
|
||||
if requested:
|
||||
if requested not in self.institute_ids:
|
||||
raise HTTPException(status_code=403, detail="Not a member of the requested institute")
|
||||
return requested
|
||||
if len(self.institute_ids) == 1:
|
||||
return self.institute_ids[0]
|
||||
if not self.institute_ids:
|
||||
raise HTTPException(status_code=403, detail="Caller has no institute membership")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="institute_id is required when the caller belongs to multiple institutes",
|
||||
)
|
||||
|
||||
|
||||
def _extract_institute_ids(rpc_data: Any) -> List[str]:
|
||||
"""Normalise the user_institute_ids() RPC result to a list of uuid strings.
|
||||
|
||||
A `returns setof uuid` function comes back from PostgREST as a JSON array of scalars,
|
||||
but tolerate the `[{"user_institute_ids": "..."}]` shape too in case of driver quirks.
|
||||
"""
|
||||
out: List[str] = []
|
||||
for row in rpc_data or []:
|
||||
if isinstance(row, dict):
|
||||
val = row.get("user_institute_ids") or next(iter(row.values()), None)
|
||||
else:
|
||||
val = row
|
||||
if val:
|
||||
out.append(str(val))
|
||||
return out
|
||||
|
||||
|
||||
async def get_exam_context(payload: Dict[str, Any] = Depends(auth)) -> ExamContext:
|
||||
user_id = payload.get("sub") or payload.get("user_id")
|
||||
access_token = payload.get("_access_token")
|
||||
if not user_id or not access_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token payload")
|
||||
|
||||
supabase = SupabaseAnonClient.for_user(access_token).supabase
|
||||
|
||||
try:
|
||||
res = supabase.rpc("user_institute_ids").execute()
|
||||
institute_ids = _extract_institute_ids(getattr(res, "data", None))
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to resolve institute memberships: {exc}")
|
||||
raise HTTPException(status_code=502, detail="Could not resolve institute membership")
|
||||
|
||||
return ExamContext(user_id, access_token, supabase, institute_ids)
|
||||
|
||||
|
||||
def lookup_exam_code(exam_id: str) -> Optional[str]:
|
||||
"""Resolve eb_exams.exam_code for a catalogue paper (denormalised onto the template).
|
||||
|
||||
Documented service-role exception (S1): eb_exams is shared exam-board reference data with
|
||||
no as-user SELECT policy (E4), so a normal user cannot read it. This is a read of public
|
||||
catalogue metadata only — not user-scoped data — and is used solely to keep the Neo4j join
|
||||
key (exam_code) correct on the template row.
|
||||
"""
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = sb.table("eb_exams").select("exam_code").eq("id", exam_id).limit(1).execute()
|
||||
rows = getattr(res, "data", None) or []
|
||||
return rows[0].get("exam_code") if rows else None
|
||||
except Exception as exc:
|
||||
logger.warning(f"exam_code lookup failed for exam_id={exam_id}: {exc}")
|
||||
return None
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Pydantic request/response models for the /api/exam/ router (S4-5).
|
||||
|
||||
Templates are saved from the canvas with a full-replace PUT (R5.2): the client owns
|
||||
stable UUIDs for questions / response areas / boundaries so the Supabase ids line up
|
||||
with the Neo4j join keys (exam_questions.id ↔ Question|Part.uuid_string,
|
||||
exam_response_areas.id ↔ Region.uuid_string — see spec §2). Granular mark-scheme edits
|
||||
go through PATCH /api/exam/questions/{qid}.
|
||||
|
||||
Models mirror the columns in volumes/db/cc/72-exam-marker.sql. They are intentionally
|
||||
permissive (most fields optional) so the canvas can round-trip partial state during
|
||||
authoring without the API rejecting work-in-progress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Templates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class CreateTemplateRequest(BaseModel):
|
||||
title: str
|
||||
subject: Optional[str] = None
|
||||
# Catalogue paper (eb_exams) the template maps, when chosen from the catalogue (R2.2).
|
||||
exam_id: Optional[str] = None
|
||||
# Denormalised onto the template for the Neo4j join (eb_exams.exam_code ↔ ExamPaper.exam_code).
|
||||
# If exam_id is given but exam_code is omitted, the API resolves it from the catalogue.
|
||||
exam_code: Optional[str] = None
|
||||
# Uploaded PDF (files.id) for an ad-hoc paper (R2.2).
|
||||
source_file_id: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
# Active institute (R1.4/R5.5). Validated against the caller's memberships; never trusted
|
||||
# as the authorization signal. Optional when the caller belongs to exactly one institute.
|
||||
institute_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateTemplateMetaRequest(BaseModel):
|
||||
"""Template-level fields that a full-replace PUT may also update alongside the canvas."""
|
||||
title: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
page_count: Optional[int] = None
|
||||
status: Optional[Literal["draft", "ready", "archived"]] = None
|
||||
|
||||
|
||||
# ─── Canvas entities (children of a template) ────────────────────────────────────
|
||||
|
||||
class QuestionPayload(BaseModel):
|
||||
# Client-supplied stable UUID (== Neo4j Question|Part.uuid_string). Optional on first save.
|
||||
id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
label: str
|
||||
order: int = 0
|
||||
max_marks: float = 0
|
||||
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
||||
mcq_options: Optional[Any] = None
|
||||
mark_scheme: Dict[str, Any] = Field(default_factory=dict)
|
||||
is_container: bool = False
|
||||
spec_ref: Optional[str] = None
|
||||
|
||||
|
||||
class ResponseAreaPayload(BaseModel):
|
||||
id: Optional[str] = None # == Neo4j Region.uuid_string
|
||||
question_id: str
|
||||
page: int
|
||||
bounds: Dict[str, Any] # {x,y,w,h}
|
||||
kind: Literal["response", "context"]
|
||||
response_form: Optional[
|
||||
Literal["lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"]
|
||||
] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
confidence: Optional[float] = None
|
||||
|
||||
|
||||
class BoundaryPayload(BaseModel):
|
||||
id: Optional[str] = None
|
||||
question_id: Optional[str] = None
|
||||
label: Optional[str] = None
|
||||
page_index: int
|
||||
y: float
|
||||
bounds: Optional[Dict[str, Any]] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class TemplateReplaceRequest(BaseModel):
|
||||
"""Full-replace canvas save (R5.2 primary path). All children are replaced wholesale."""
|
||||
meta: Optional[UpdateTemplateMetaRequest] = None
|
||||
questions: List[QuestionPayload] = Field(default_factory=list)
|
||||
response_areas: List[ResponseAreaPayload] = Field(default_factory=list)
|
||||
boundaries: List[BoundaryPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PatchQuestionRequest(BaseModel):
|
||||
"""Incremental mark-scheme / spec-ref edit (R5.2 granular path)."""
|
||||
label: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
max_marks: Optional[float] = None
|
||||
answer_type: Optional[Literal["written", "mcq", "short", "diagram"]] = None
|
||||
mcq_options: Optional[Any] = None
|
||||
mark_scheme: Optional[Dict[str, Any]] = None
|
||||
is_container: Optional[bool] = None
|
||||
spec_ref: Optional[str] = None
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Template CRUD for the exam-marker (/api/exam/templates...) — card S4-5.
|
||||
|
||||
All access is as-the-user (RLS-enforced; spec E1 fix) via ExamContext. Ownership is also
|
||||
checked explicitly before mutating (E2: never trust a client-supplied id as authorization) —
|
||||
defence in depth on top of RLS. A row the caller cannot see under RLS reads back as absent,
|
||||
so cross-institute access surfaces as 404, never a data leak (IDOR-safe).
|
||||
|
||||
Hybrid persistence (R5.2): PUT /templates/{id} is a full-replace of the canvas children
|
||||
(questions + response areas + boundaries); PATCH /questions/{qid} is the granular mark-scheme
|
||||
edit path. Client-supplied UUIDs are preserved so Supabase ids stay aligned with the Neo4j
|
||||
join keys (spec §2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, lookup_exam_code
|
||||
from routers.exam.schemas import (
|
||||
CreateTemplateRequest,
|
||||
PatchQuestionRequest,
|
||||
TemplateReplaceRequest,
|
||||
)
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _first(result: Any) -> Optional[Dict[str, Any]]:
|
||||
rows = _rows(result)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _fetch_template_or_404(ctx: ExamContext, template_id: str) -> Dict[str, Any]:
|
||||
"""Load a template the caller can see (RLS-scoped). Missing/forbidden → 404."""
|
||||
res = ctx.supabase.table("exam_templates").select("*").eq("id", template_id).limit(1).execute()
|
||||
row = _first(res)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return row
|
||||
|
||||
|
||||
def _require_owner(ctx: ExamContext, template: Dict[str, Any]) -> None:
|
||||
"""Writes are limited to the owning teacher (R2.4). RLS also enforces this; we pre-check
|
||||
so a colleague who can *read* the template gets a clean 403 instead of a silent no-op."""
|
||||
if template.get("teacher_id") != ctx.user_id:
|
||||
raise HTTPException(status_code=403, detail="Only the template owner can modify it")
|
||||
|
||||
|
||||
# ─── templates ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(
|
||||
body: CreateTemplateRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
institute_id = ctx.resolve_institute(body.institute_id)
|
||||
|
||||
exam_code = body.exam_code
|
||||
if body.exam_id and not exam_code:
|
||||
exam_code = lookup_exam_code(body.exam_id)
|
||||
|
||||
row = {
|
||||
"title": body.title,
|
||||
"subject": body.subject,
|
||||
"exam_id": body.exam_id,
|
||||
"exam_code": exam_code,
|
||||
"source_file_id": body.source_file_id,
|
||||
"page_count": body.page_count,
|
||||
"institute_id": institute_id,
|
||||
"teacher_id": ctx.user_id,
|
||||
"status": "draft",
|
||||
}
|
||||
row = {k: v for k, v in row.items() if v is not None}
|
||||
|
||||
res = ctx.supabase.table("exam_templates").insert(row).execute()
|
||||
created = _first(res)
|
||||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to create template")
|
||||
logger.info(f"Exam template created: {created.get('id')} by {ctx.user_id}")
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
include_archived: bool = False,
|
||||
institute_id: Optional[str] = None,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
# RLS already scopes to the caller's institutes; the optional filter narrows within that.
|
||||
q = ctx.supabase.table("exam_templates").select("*")
|
||||
if institute_id:
|
||||
q = q.eq("institute_id", institute_id)
|
||||
if not include_archived:
|
||||
q = q.neq("status", "archived")
|
||||
res = q.order("updated_at", desc=True).execute()
|
||||
return {"templates": _rows(res)}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
questions = _rows(
|
||||
ctx.supabase.table("exam_questions").select("*").eq("template_id", template_id).order("order").execute()
|
||||
)
|
||||
response_areas = _rows(
|
||||
ctx.supabase.table("exam_response_areas").select("*").eq("template_id", template_id).execute()
|
||||
)
|
||||
boundaries = _rows(
|
||||
ctx.supabase.table("exam_boundaries").select("*").eq("template_id", template_id).execute()
|
||||
)
|
||||
return {
|
||||
**template,
|
||||
"questions": questions,
|
||||
"response_areas": response_areas,
|
||||
"boundaries": boundaries,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def replace_template(
|
||||
template_id: str,
|
||||
body: TemplateReplaceRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Full-replace canvas save (R5.2). Replaces questions/response_areas/boundaries wholesale.
|
||||
|
||||
Note: the delete-then-insert spans several PostgREST calls and is therefore not atomic;
|
||||
acceptable for the small (~20-question) payloads this carries. A transactional RPC is a
|
||||
later hardening step if concurrent canvas saves become a concern.
|
||||
"""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, 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}
|
||||
if updates:
|
||||
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
|
||||
|
||||
sb = ctx.supabase
|
||||
# Clear existing children. Order matters: response_areas/boundaries reference questions, so
|
||||
# remove them first (we delete by template_id rather than rely on cascade for predictability).
|
||||
sb.table("exam_response_areas").delete().eq("template_id", template_id).execute()
|
||||
sb.table("exam_boundaries").delete().eq("template_id", template_id).execute()
|
||||
sb.table("exam_questions").delete().eq("template_id", template_id).execute()
|
||||
|
||||
# Re-insert, preserving client-supplied UUIDs (Neo4j join keys, spec §2).
|
||||
if body.questions:
|
||||
q_rows = []
|
||||
for q in body.questions:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"parent_id": q.parent_id,
|
||||
"label": q.label,
|
||||
"order": q.order,
|
||||
"max_marks": q.max_marks,
|
||||
"answer_type": q.answer_type,
|
||||
"mcq_options": q.mcq_options,
|
||||
"mark_scheme": q.mark_scheme,
|
||||
"is_container": q.is_container,
|
||||
"spec_ref": q.spec_ref,
|
||||
}
|
||||
if q.id:
|
||||
r["id"] = q.id
|
||||
q_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_questions").insert(q_rows).execute()
|
||||
|
||||
if body.response_areas:
|
||||
ra_rows = []
|
||||
for ra in body.response_areas:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"question_id": ra.question_id,
|
||||
"page": ra.page,
|
||||
"bounds": ra.bounds,
|
||||
"kind": ra.kind,
|
||||
"response_form": ra.response_form,
|
||||
"source": ra.source,
|
||||
"confirmed": ra.confirmed,
|
||||
"confidence": ra.confidence,
|
||||
}
|
||||
if ra.id:
|
||||
r["id"] = ra.id
|
||||
ra_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_response_areas").insert(ra_rows).execute()
|
||||
|
||||
if body.boundaries:
|
||||
b_rows = []
|
||||
for b in body.boundaries:
|
||||
r = {
|
||||
"template_id": template_id,
|
||||
"question_id": b.question_id,
|
||||
"label": b.label,
|
||||
"page_index": b.page_index,
|
||||
"y": b.y,
|
||||
"bounds": b.bounds,
|
||||
"source": b.source,
|
||||
"confirmed": b.confirmed,
|
||||
}
|
||||
if b.id:
|
||||
r["id"] = b.id
|
||||
b_rows.append({k: v for k, v in r.items() if v is not None})
|
||||
sb.table("exam_boundaries").insert(b_rows).execute()
|
||||
|
||||
logger.info(
|
||||
f"Exam template {template_id} replaced: {len(body.questions)} questions, "
|
||||
f"{len(body.response_areas)} regions, {len(body.boundaries)} boundaries"
|
||||
)
|
||||
return await get_template(template_id, ctx)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def archive_template(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Soft-delete: status='archived' (R5.2). Never hard-deletes a teacher's work."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_owner(ctx, template)
|
||||
ctx.supabase.table("exam_templates").update({"status": "archived"}).eq("id", template_id).execute()
|
||||
return {"status": "archived", "id": template_id}
|
||||
|
||||
|
||||
# ─── questions (granular edit path, R5.2) ────────────────────────────────────
|
||||
|
||||
@router.patch("/questions/{question_id}")
|
||||
async def patch_question(
|
||||
question_id: str,
|
||||
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}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
# RLS (exam_questions_all) enforces that the question belongs to a template owned by the
|
||||
# caller; an out-of-scope id updates zero rows → 404, so no explicit pre-fetch is needed.
|
||||
res = ctx.supabase.table("exam_questions").update(updates).eq("id", question_id).execute()
|
||||
updated = _first(res)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
return updated
|
||||
Reference in New Issue
Block a user