d9af8420bc
REVIEW-5 / PUBLISH-45: owner edits the stored photo lineup in Review — tap a card to keep/remove, drag to reorder, leftmost kept photo = primary (Woo cover). The kept set must contain a clean calibration + its dimension-overlay copy; publish is blocked until it does. - backend: role column on slab_photos (schema + idempotent _migrate ALTER), role in photo CRUD + new DELETE endpoint, Woo image order by role-rank (clean→overlay→batch) per PUBLISH-45 - frontend: Review photo strip (render from listPhotos, keep/remove, HTML5 drag-reorder, dynamic PRIMARY badge from .primary class), persistLineup() re-seq kept + drop removed before entering Publish - tests: lineup.ts unit, e2e review-photo-strip.spec, backend role + order Refs: #90, REVIEW-5, PUBLISH-45
136 lines
5.5 KiB
Python
136 lines
5.5 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"))
|
|
# 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, 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)
|