"""Photo BLOB storage (S3). Multipart upload of original + processed variants into slab_photos; GET original/processed while retained; 410 Gone once purged. Cites CAPTURE-95/105/130 (lineup order), PUBLISH-40 (processed PNGs, no cap). """ from typing import Any from fastapi import APIRouter, File, Form, UploadFile from fastapi.responses import JSONResponse, Response from .db import get_connection router = APIRouter(prefix="/api/v1/slabs") def _error(code: str, message: str, detail: Any = None) -> dict: return {"error": {"code": code, "message": message, "detail": detail}} def _get_slab(conn, slab_id: str): return conn.execute( "SELECT id, deleted_at FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,) ).fetchone() def _get_photo(conn, slab_id: str, photo_id: str): row = conn.execute( "SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role " "FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id), ).fetchone() return row def _next_seq(conn, slab_id: str) -> int: row = conn.execute("SELECT COALESCE(MAX(seq),0)+1 FROM slab_photos WHERE slab_id=?", (slab_id,)).fetchone() return row[0] @router.post("/{slab_id}/photos", status_code=201) async def upload_photo( slab_id: str, photo_id: str = Form(...), seq: int | None = Form(None), is_calibration: int = Form(0), role: str = Form("batch"), original: UploadFile = File(...), processed: UploadFile | None = File(None), ) -> dict: conn = get_connection() if _get_slab(conn, slab_id) is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) if is_calibration not in (0, 1): return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1")) if role not in ("calibration", "overlay", "batch"): return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch")) orig_bytes = await original.read() proc_bytes = await processed.read() if processed is not None else None if not orig_bytes: return JSONResponse(status_code=400, content=_error("validation", "original photo is empty")) # slab_photos.id is a global primary key and photo ids are derived from the # slab id, so a re-upload must clear the id regardless of slab: a retried or # concurrent upload of the same draft would otherwise hit UNIQUE(id). conn.execute("DELETE FROM slab_photos WHERE id=?", (photo_id,)) seq_val = seq if seq is not None else _next_seq(conn, slab_id) conn.execute( "INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) " "VALUES (?,?,?,?,?,?,?,?)", (photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()), ) conn.commit() row = _get_photo(conn, slab_id, photo_id) return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"], "role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None} def _now() -> str: from datetime import datetime, timezone return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() @router.get("/{slab_id}/photos/{photo_id}/original") async def get_original(slab_id: str, photo_id: str): conn = get_connection() if _get_slab(conn, slab_id) is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) row = _get_photo(conn, slab_id, photo_id) if row is None: return JSONResponse(status_code=404, content=_error("not_found", "Photo not found")) if row["original_blob"] is None: return Response(status_code=410) return Response(content=row["original_blob"], media_type="image/png") @router.get("/{slab_id}/photos/{photo_id}/processed") async def get_processed(slab_id: str, photo_id: str): conn = get_connection() if _get_slab(conn, slab_id) is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) row = _get_photo(conn, slab_id, photo_id) if row is None: return JSONResponse(status_code=404, content=_error("not_found", "Photo not found")) if row["processed_blob"] is None: return Response(status_code=410) return Response(content=row["processed_blob"], media_type="image/png") @router.get("/{slab_id}/photos") async def list_photos(slab_id: str) -> list[dict]: conn = get_connection() if _get_slab(conn, slab_id) is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) rows = conn.execute( "SELECT id, seq, is_calibration, role, width_px, height_px, " "(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed " "FROM slab_photos WHERE slab_id=? ORDER BY seq", (slab_id,), ).fetchall() return [dict(r) for r in rows] @router.delete("/{slab_id}/photos/{photo_id}", status_code=204) async def delete_photo(slab_id: str, photo_id: str): """#90: drop a photo the owner removed in Review (keep/remove edit). The remaining photos are re-numbered by the client via re-upload.""" conn = get_connection() if _get_slab(conn, slab_id) is None: return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) cur = conn.execute( "DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id) ) conn.commit() if cur.rowcount == 0: return JSONResponse(status_code=404, content=_error("not_found", "Photo not found")) return Response(status_code=204)