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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user