feat(review): #90 photo strip — keep/remove, drag-reorder, leftmost=primary, persist

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
This commit is contained in:
Ty
2026-09-21 01:34:22 -07:00
parent fe82133ca4
commit d9af8420bc
11 changed files with 806 additions and 29 deletions
+19
View File
@@ -80,6 +80,21 @@ _M3_TABLES = [
]
def _migrate(conn: sqlite3.Connection) -> None:
"""Idempotent column additions for volumes created before a column existed.
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
so new columns must be added here. Each guard is a no-op once the column is
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
"""
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
if photo_cols and "role" not in photo_cols:
conn.execute(
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
"CHECK(role IN ('calibration','overlay','batch'))"
)
def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn = get_connection(db_path)
schema_path = _get_schema_path()
@@ -99,6 +114,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn.executescript(schema)
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
conn.commit()
# Safe to run every boot: no-op once the columns exist.
with _lock:
_migrate(conn)
conn.commit()
return conn
+3
View File
@@ -57,6 +57,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
slab_id TEXT NOT NULL,
seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
original_blob BLOB,
processed_blob BLOB,
width_px INTEGER,
+25 -6
View File
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
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 "
"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()
@@ -44,6 +44,7 @@ async def upload_photo(
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:
@@ -52,6 +53,8 @@ async def upload_photo(
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:
@@ -60,14 +63,14 @@ async def upload_photo(
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()),
"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"],
"original_retained": True, "processed_retained": row["processed_blob"] is not None}
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
def _now() -> str:
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
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, "
"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)
+8 -1
View File
@@ -150,9 +150,16 @@ async def publish(slab_id: str) -> dict:
code, msg, rec = _classify(e)
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
# 4/5. Upload processed PNGs, then POST product.
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
# its dimension-overlay copy, then the remaining batch photos. The Review
# photo strip stores the owner's keep order (leftmost = primary), so the
# overlay (which can sit after a moved batch photo) is re-ordered here by
# role rather than by stored seq. Ties fall back to stored seq.
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
photos = conn.execute(
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq",
"SELECT id, processed_blob, role FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL "
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
(slab_id,),
).fetchall()
uploaded_ids: list[int] = []
+41 -4
View File
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-bytes"
PNG_PROC = b"\x89PNG-proc-bytes"
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0):
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
files = {"original": ("orig.png", original, "image/png")}
if processed is not None:
files["processed"] = ("proc.png", processed, "image/png")
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
if seq is not None:
data["seq"] = str(seq)
return client.post(
f"/api/v1/slabs/{slab_id}/photos",
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
data=data,
files=files,
)
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
def test_list_photos(photos_env):
client, _ = photos_env
_multipart(client, "s1", "pa", is_calibration=1)
_multipart(client, "s1", "pb")
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
_multipart(client, "s1", "pb", role="overlay")
r = client.get("/api/v1/slabs/s1/photos")
assert r.status_code == 200
ids = [row["id"] for row in r.json()]
assert "pa" in ids and "pb" in ids
def test_upload_stores_and_returns_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r1", role="overlay")
assert r.status_code == 201
assert r.json()["role"] == "overlay"
r = client.get("/api/v1/slabs/s1/photos")
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
assert row["role"] == "overlay"
def test_upload_rejects_bad_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r2", role="not-a-role")
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
def test_delete_photo(photos_env):
client, _ = photos_env
_multipart(client, "s1", "d1")
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
r = client.delete("/api/v1/slabs/s1/photos/d1")
assert r.status_code == 204
# Gone from the list and blob no longer served.
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
assert "d1" not in ids
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
def test_delete_photo_not_found(photos_env):
client, _ = photos_env
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
+47
View File
@@ -119,6 +119,53 @@ def test_publish_success(env, monkeypatch):
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
def test_publish_woo_image_order_by_role(env, monkeypatch):
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
# role, not by stored seq (the Review strip stores the owner's keep order).
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
# overlay(3). The publish query must reorder to cal, overlay, batch.
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
conn.commit()
media_calls = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
media_calls.append(data)
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(media_calls) == 3
# The photo id is embedded in the multipart filename (slab.py publish
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
# batch (PUBLISH-45), not stored seq.
def photo_id(body: bytes) -> str:
import re
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
return m.group(1).decode() if m else ""
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
def test_uat_draft(env, monkeypatch):
client, conn = env
s = _full_slab(sku="SLAB-UAT-1")