104f29f5e3
slab_photos.id is a global primary key and photo ids are derived from the slab id, so scoping the replace to the slab let a retried or concurrent upload of the same draft hit UNIQUE(id) and 500. Clear the id unconditionally.
138 lines
5.7 KiB
Python
138 lines
5.7 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, 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)
|