Files
SlabUploader/backend/photos.py
T
Ty 714a227e57 feat(photos): S3 multipart BLOB storage + original/processed GET + 410 after purge + attach UI
- backend/photos.py: POST /slabs/{id}/photos (multipart original+processed ->
  slab_photos BLOBs, re-upload replaces), GET original/processed (image/png, 410
  once purged), GET list. Cites CAPTURE-95/105/130, PUBLISH-40.
- frontend: create/photos/PhotoAttach.svelte (slab id, original/processed files,
  calibration flag, lineup) + dev toggle.
- tests: 5 photos tests (upload+get both variants, 410 after purge, 404 slab,
  reupload replaces, list).

Verified: pytest 36 passed; gates exit 0; npm build ok; live QA upload->200,
get original/processed->200, purge->410.
2026-09-19 16:47:07 -07:00

117 lines
4.6 KiB
Python

"""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 "
"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),
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"))
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"))
# Replace any existing photo with the same id on this slab (re-upload).
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_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, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, 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"],
"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, 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]