feat(exam): template source PDF at create + GET /templates/{id}/source-pdf (S4-8.1)
Recovered from cc-worker WIP that was left uncommitted in the dev-centre clone (card t_0055b89b). Multipart source_pdf upload at create -> source_file_id; source-pdf download endpoint resolves from exam_id (catalogue) or source_file_id. NOT yet human-reviewed/merged; preserving + verifying so it isn't clobbered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9c1aee28e2
commit
c58df6715c
@ -13,11 +13,15 @@ join keys (spec §2).
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from modules.database.services.exam_projection import project_template, project_template_safe
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.supabase.utils.storage import StorageAdmin
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, lookup_exam_code
|
||||
from routers.exam.schemas import (
|
||||
@ -30,6 +34,9 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SOURCE_CABINET_NAME = "Exam Marker Template Sources"
|
||||
SOURCE_BUCKET_FALLBACK = "cc.users"
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -55,12 +62,19 @@ def _fetch_template_or_404(ctx: ExamContext, template_id: str) -> Dict[str, Any]
|
||||
|
||||
|
||||
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."""
|
||||
"""Writes are limited to the owning teacher (R2.4)."""
|
||||
if template.get("teacher_id") != ctx.user_id:
|
||||
raise HTTPException(status_code=403, detail="Only the template owner can modify it")
|
||||
|
||||
|
||||
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."""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
|
||||
def _template_has_recorded_marks(ctx: ExamContext, template_id: str) -> bool:
|
||||
"""True if any mark_entry exists for a batch of this template (→ destructive PUT is unsafe)."""
|
||||
batches = _rows(
|
||||
@ -75,25 +89,144 @@ def _template_has_recorded_marks(ctx: ExamContext, template_id: str) -> bool:
|
||||
return bool(marks)
|
||||
|
||||
|
||||
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:
|
||||
raise ValueError(f"Invalid storage_loc: {storage_loc!r}")
|
||||
return bucket, path
|
||||
|
||||
|
||||
def _lookup_exam_storage_loc(exam_id: str) -> Optional[str]:
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = sb.table("eb_exams").select("storage_loc").eq("id", exam_id).limit(1).execute()
|
||||
row = _first(res)
|
||||
return row.get("storage_loc") if row else None
|
||||
except Exception as exc:
|
||||
logger.warning(f"storage_loc lookup failed for exam_id={exam_id}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def _parse_create_template_request(request: Request) -> tuple[CreateTemplateRequest, Optional[UploadFile]]:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "multipart/form-data" in content_type:
|
||||
form = await request.form()
|
||||
payload: Dict[str, Any] = {}
|
||||
for key in ("title", "subject", "exam_id", "exam_code", "source_file_id", "page_count", "institute_id"):
|
||||
value = form.get(key)
|
||||
if value is not None and value != "":
|
||||
payload[key] = value
|
||||
upload = form.get("source_pdf")
|
||||
if upload is not None and not hasattr(upload, "read"):
|
||||
raise HTTPException(status_code=400, detail="source_pdf must be a file upload")
|
||||
if upload is not None and payload.get("source_file_id"):
|
||||
raise HTTPException(status_code=400, detail="Use either source_file_id or source_pdf, not both")
|
||||
return CreateTemplateRequest(**payload), upload
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid request body: {exc}")
|
||||
return CreateTemplateRequest(**data), None
|
||||
|
||||
|
||||
async def _upload_template_source_file(
|
||||
ctx: ExamContext,
|
||||
institute_id: str,
|
||||
upload: UploadFile,
|
||||
) -> str:
|
||||
file_bytes = await upload.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(status_code=400, detail="Uploaded PDF is empty")
|
||||
if upload.content_type and upload.content_type != "application/pdf":
|
||||
raise HTTPException(status_code=400, detail="Uploaded file must be a PDF")
|
||||
|
||||
service = SupabaseServiceRoleClient()
|
||||
storage = StorageAdmin()
|
||||
|
||||
cabinet_name = SOURCE_CABINET_NAME
|
||||
existing = _first(
|
||||
service.supabase.table("file_cabinets")
|
||||
.select("id")
|
||||
.eq("user_id", ctx.user_id)
|
||||
.eq("name", cabinet_name)
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if existing:
|
||||
cabinet_id = existing["id"]
|
||||
else:
|
||||
created_cabinet = _first(
|
||||
service.supabase.table("file_cabinets")
|
||||
.insert({"user_id": ctx.user_id, "name": cabinet_name})
|
||||
.execute()
|
||||
)
|
||||
if not created_cabinet:
|
||||
raise HTTPException(status_code=500, detail="Failed to create upload cabinet")
|
||||
cabinet_id = created_cabinet["id"]
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
safe_name = os.path.basename(upload.filename or "template.pdf")
|
||||
bucket = f"cc.institutes.{institute_id}.private" if institute_id else SOURCE_BUCKET_FALLBACK
|
||||
storage_path = f"exam-marker/{cabinet_id}/{file_id}/{safe_name}"
|
||||
|
||||
try:
|
||||
storage.upload_file(bucket, storage_path, file_bytes, "application/pdf", upsert=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Storage upload failed: {exc}")
|
||||
|
||||
inserted = _first(
|
||||
service.supabase.table("files").insert(
|
||||
{
|
||||
"id": file_id,
|
||||
"cabinet_id": cabinet_id,
|
||||
"name": safe_name,
|
||||
"path": storage_path,
|
||||
"bucket": bucket,
|
||||
"mime_type": "application/pdf",
|
||||
"uploaded_by": ctx.user_id,
|
||||
"size_bytes": len(file_bytes),
|
||||
"source": "classroomcopilot-web",
|
||||
"is_directory": False,
|
||||
"relative_path": safe_name,
|
||||
"processing_status": "uploaded",
|
||||
}
|
||||
).execute()
|
||||
)
|
||||
if not inserted:
|
||||
raise HTTPException(status_code=500, detail="Failed to create file record")
|
||||
|
||||
return file_id
|
||||
|
||||
|
||||
# ─── templates ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(
|
||||
body: CreateTemplateRequest,
|
||||
request: Request,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
body, upload = await _parse_create_template_request(request)
|
||||
institute_id = ctx.resolve_institute(body.institute_id)
|
||||
|
||||
if body.exam_id and body.source_file_id:
|
||||
raise HTTPException(status_code=400, detail="Use either exam_id or source_file_id, not both")
|
||||
|
||||
exam_code = body.exam_code
|
||||
if body.exam_id and not exam_code:
|
||||
exam_code = lookup_exam_code(body.exam_id)
|
||||
|
||||
source_file_id = body.source_file_id
|
||||
if upload is not None:
|
||||
source_file_id = await _upload_template_source_file(ctx, institute_id, upload)
|
||||
|
||||
row = {
|
||||
"title": body.title,
|
||||
"subject": body.subject,
|
||||
"exam_id": body.exam_id,
|
||||
"exam_code": exam_code,
|
||||
"source_file_id": body.source_file_id,
|
||||
"source_file_id": source_file_id,
|
||||
"page_count": body.page_count,
|
||||
"institute_id": institute_id,
|
||||
"teacher_id": ctx.user_id,
|
||||
@ -109,6 +242,23 @@ async def create_template(
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/catalogue")
|
||||
async def list_catalogue_papers() -> Dict[str, Any]:
|
||||
"""Lightweight exam-board paper catalogue for the create dialog."""
|
||||
try:
|
||||
sb = SupabaseServiceRoleClient().supabase
|
||||
res = (
|
||||
sb.table("eb_exams")
|
||||
.select("id, exam_code, spec_code, paper_code, tier, session, type_code, storage_loc")
|
||||
.eq("type_code", "QP")
|
||||
.order("exam_code")
|
||||
.execute()
|
||||
)
|
||||
return {"papers": _rows(res)}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Could not load catalogue papers: {exc}")
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
include_archived: bool = False,
|
||||
@ -148,6 +298,52 @@ async def get_template(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}/source-pdf")
|
||||
async def get_template_source_pdf(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Response:
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_source_visibility_or_404(ctx, template)
|
||||
|
||||
bucket: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
|
||||
if template.get("exam_id"):
|
||||
storage_loc = _lookup_exam_storage_loc(template["exam_id"])
|
||||
if not storage_loc:
|
||||
raise HTTPException(status_code=404, detail="Template source not found")
|
||||
try:
|
||||
bucket, path = _parse_storage_loc(storage_loc)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Template source not found")
|
||||
elif template.get("source_file_id"):
|
||||
file_row = _first(
|
||||
ctx.supabase.table("files")
|
||||
.select("bucket, path, mime_type, name")
|
||||
.eq("id", template["source_file_id"])
|
||||
.limit(1)
|
||||
.execute()
|
||||
)
|
||||
if not file_row or not file_row.get("bucket") or not file_row.get("path"):
|
||||
raise HTTPException(status_code=404, detail="Template source not found")
|
||||
bucket = file_row["bucket"]
|
||||
path = file_row["path"]
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Template source not found")
|
||||
|
||||
if not bucket or not path:
|
||||
raise HTTPException(status_code=404, detail="Template source not found")
|
||||
|
||||
try:
|
||||
pdf_bytes = StorageAdmin().download_file(bucket, path)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Template source download failed for template {template_id}: {exc}")
|
||||
raise HTTPException(status_code=404, detail="Template source not found")
|
||||
|
||||
return Response(content=pdf_bytes, media_type="application/pdf")
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def replace_template(
|
||||
template_id: str,
|
||||
@ -293,6 +489,7 @@ async def neo4j_sync(
|
||||
|
||||
# ─── questions (granular edit path, R5.2) ────────────────────────────────────
|
||||
|
||||
|
||||
@router.patch("/questions/{question_id}")
|
||||
async def patch_question(
|
||||
question_id: str,
|
||||
|
||||
@ -136,6 +136,19 @@ class FakeSupabase:
|
||||
return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
class _FakeStorageAdmin:
|
||||
def upload_file(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def download_file(self, bucket_id, file_path):
|
||||
return b"%PDF-1.7 fake"
|
||||
|
||||
|
||||
class _FakeServiceRoleClient:
|
||||
def __init__(self, store):
|
||||
self.supabase = FakeSupabase(store)
|
||||
|
||||
|
||||
def make_client(user_id=TEACHER, institute_ids=(INST_A,), store=None):
|
||||
store = store if store is not None else {}
|
||||
app = FastAPI()
|
||||
@ -169,6 +182,44 @@ def test_create_template_sets_owner_and_institute():
|
||||
assert row["status"] == "draft"
|
||||
|
||||
|
||||
def test_create_template_accepts_uploaded_source_pdf(monkeypatch):
|
||||
store = {}
|
||||
client, store = make_client(store=store)
|
||||
monkeypatch.setattr(templates_mod, "StorageAdmin", _FakeStorageAdmin)
|
||||
monkeypatch.setattr(templates_mod, "SupabaseServiceRoleClient", lambda: _FakeServiceRoleClient(store))
|
||||
|
||||
resp = client.post(
|
||||
"/api/exam/templates",
|
||||
data={"title": "AQA Physics 1H", "subject": "Physics"},
|
||||
files={"source_pdf": ("paper.pdf", b"%PDF-1.7 test", "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()
|
||||
assert row["source_file_id"] is not None
|
||||
assert store["files"][0]["id"] == row["source_file_id"]
|
||||
assert store["files"][0]["uploaded_by"] == TEACHER
|
||||
|
||||
|
||||
def test_get_template_source_pdf_from_uploaded_file(monkeypatch):
|
||||
store = {
|
||||
"exam_templates": [{
|
||||
"id": "t1",
|
||||
"title": "p",
|
||||
"status": "draft",
|
||||
"institute_id": INST_A,
|
||||
"teacher_id": TEACHER,
|
||||
"source_file_id": "f1",
|
||||
}],
|
||||
"files": [{"id": "f1", "bucket": "cc.users", "path": "exam-marker/cab1/f1/paper.pdf", "name": "paper.pdf"}],
|
||||
}
|
||||
client, _ = make_client(store=store)
|
||||
monkeypatch.setattr(templates_mod, "StorageAdmin", _FakeStorageAdmin)
|
||||
resp = client.get("/api/exam/templates/t1/source-pdf")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/pdf")
|
||||
assert resp.content.startswith(b"%PDF-1.7")
|
||||
|
||||
|
||||
def test_create_template_rejects_foreign_institute():
|
||||
client, _ = make_client(institute_ids=(INST_A,))
|
||||
resp = client.post("/api/exam/templates", json={"title": "X", "institute_id": INST_B})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user