feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
+59
-66
@@ -1,13 +1,15 @@
|
||||
"""Publish sequence (S6). Cites PUBLISH-5/10/15/20/25/30/31/36/37/38/55/60.
|
||||
"""Publish sequence (PUBLISH-5/10/15/20/25/30/31/36/37/38/45/55/60).
|
||||
|
||||
Sequence (PUBLISH-5): validate ready set -> look up SKU (stop if exists) ->
|
||||
refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload processed
|
||||
PNGs -> POST /products -> finalize. SLAB-UAT-* always draft (PUBLISH-10).
|
||||
Failure: mark failed, retain photos, write sync_log, plain message (PUBLISH-30).
|
||||
Partial (media ok, product POST failed): orphan media left, logged (PUBLISH-31).
|
||||
refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload
|
||||
processed PNGs -> POST /products -> finalize. SLAB-UAT-* always draft
|
||||
(PUBLISH-10). Failure: mark failed, retain photos, write sync_log, plain
|
||||
message (PUBLISH-30). Partial (media ok, product POST failed): orphan media
|
||||
left, logged (PUBLISH-31).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
@@ -24,7 +26,7 @@ from .woo import (
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
)
|
||||
from .review import compute_ready, _slab_dict
|
||||
from .review import _slab_dict, compute_ready
|
||||
|
||||
router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
@@ -59,6 +61,24 @@ def _taxonomy_stale(conn) -> bool:
|
||||
return datetime.now(timezone.utc) - last > timedelta(hours=1)
|
||||
|
||||
|
||||
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
|
||||
"""Match a locked attribute against the synced Woo set by slug, then name.
|
||||
|
||||
METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids. Accepts
|
||||
both 'figure' and 'pa_figure' style slugs from Woo.
|
||||
"""
|
||||
slug_l = slug.lower().lstrip()
|
||||
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
|
||||
for a in attrs.values():
|
||||
if (a.get("slug") or "").lower() in slug_alts:
|
||||
return a["id"]
|
||||
name_l = name.lower()
|
||||
for a in attrs.values():
|
||||
if (a.get("name") or "").lower() == name_l:
|
||||
return a["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
payload: dict[str, Any] = {
|
||||
"type": "simple",
|
||||
@@ -75,13 +95,12 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
"sold_individually": True,
|
||||
}
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
payload["categories"] = [{"id": slab["species_id"]}]+[{"id": c} for c in wood_cats]
|
||||
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
||||
payload["categories"] = [{"id": slab["species_id"]}] + [{"id": c} for c in wood_cats]
|
||||
tags = [t for t in (slab.get("fig_tag_ids") or [])] + [t for t in (slab.get("feat_tag_ids") or [])]
|
||||
if tags:
|
||||
payload["tags"] = [{"id": t} for t in tags]
|
||||
# Five locked attributes: resolve the attribute row from the synced cache by
|
||||
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
|
||||
# then fall back to case-insensitive name. Term *options* are always the
|
||||
# Five locked attributes: resolve each attribute from the synced cache by
|
||||
# slug first, then case-insensitive name. Term *options* are always the
|
||||
# names of the selected synced terms.
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
||||
@@ -104,22 +123,6 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
||||
return payload
|
||||
|
||||
|
||||
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
|
||||
"""Match a locked attribute against the synced Woo set by slug, then name."""
|
||||
slug_l = slug.lower().lstrip()
|
||||
# Accept both "figure" and "pa_figure" style slugs from Woo.
|
||||
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
|
||||
for a in attrs.values():
|
||||
s = (a.get("slug") or "").lower()
|
||||
if s in slug_alts:
|
||||
return a["id"]
|
||||
name_l = name.lower()
|
||||
for a in attrs.values():
|
||||
if (a.get("name") or "").lower() == name_l:
|
||||
return a["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
|
||||
if isinstance(e, WooCredentialsMissing):
|
||||
@@ -138,6 +141,25 @@ def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||
return "woo_failed", "The store create failed. You can retry.", True
|
||||
|
||||
|
||||
def _woo_creds(conn) -> tuple[str, str | None, str | None]:
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (u[0] if u else None), (p[0] if p else None)
|
||||
|
||||
|
||||
def _create_status(conn) -> str:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone()
|
||||
return row[0] if row and row[0] else "draft"
|
||||
|
||||
|
||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)",
|
||||
(slab_id, woo_product_id, status, None, message, _now()),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slab_id}/publish")
|
||||
async def publish(slab_id: str) -> dict:
|
||||
conn = get_connection()
|
||||
@@ -156,7 +178,7 @@ async def publish(slab_id: str) -> dict:
|
||||
except WooCredentialsMissing as e:
|
||||
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
|
||||
try:
|
||||
existing = _sku_exists(client, slab["sku"])
|
||||
existing = len(client.get_products_by_sku(slab["sku"])) > 0
|
||||
except WooError as e:
|
||||
code, msg, rec = _classify(e)
|
||||
_log(conn, slab_id, None, "failed", msg)
|
||||
@@ -168,9 +190,9 @@ async def publish(slab_id: str) -> dict:
|
||||
return JSONResponse(status_code=409, content=_error("duplicate_sku", "SKU already exists", {"sku": slab["sku"]}))
|
||||
# 3. Refresh taxonomy if stale (SETTINGS-60).
|
||||
if _taxonomy_stale(conn):
|
||||
try:
|
||||
from . import taxonomy as taxonomy_mod
|
||||
from . import taxonomy as taxonomy_mod
|
||||
|
||||
try:
|
||||
taxonomy_mod.sync(client, conn)
|
||||
except WooError as e:
|
||||
code, msg, rec = _classify(e)
|
||||
@@ -180,7 +202,7 @@ async def publish(slab_id: str) -> dict:
|
||||
# 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.
|
||||
# role rather than 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, role FROM slab_photos "
|
||||
@@ -195,7 +217,7 @@ async def publish(slab_id: str) -> dict:
|
||||
payload = _build_payload(slab, conn, status)
|
||||
if uploaded_ids:
|
||||
payload["images"] = [{"id": i} for i in uploaded_ids]
|
||||
woo_product_id = _create_product(client, payload)
|
||||
woo_product_id = client.create_product(payload)
|
||||
except WooError as e:
|
||||
code, msg, rec = _classify(e)
|
||||
partial = bool(uploaded_ids)
|
||||
@@ -207,8 +229,10 @@ async def publish(slab_id: str) -> dict:
|
||||
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec, "partial_media": partial}))
|
||||
# 6. Finalize (PUBLISH-25): set woo_product_id, purge photos, discard local row.
|
||||
now = _now()
|
||||
conn.execute("UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?",
|
||||
(woo_product_id, now, now, slab_id))
|
||||
conn.execute(
|
||||
"UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?",
|
||||
(woo_product_id, now, now, slab_id),
|
||||
)
|
||||
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE slab_id=?", (slab_id,))
|
||||
_log(conn, slab_id, woo_product_id, "success", "published")
|
||||
conn.commit()
|
||||
@@ -218,34 +242,3 @@ async def publish(slab_id: str) -> dict:
|
||||
"woo_product_id": woo_product_id,
|
||||
"draft": _is_uat(slab["sku"]),
|
||||
}
|
||||
|
||||
|
||||
def _woo_creds(conn) -> tuple[str, str | None, str | None]:
|
||||
import os
|
||||
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (u[0] if u else None), (p[0] if p else None)
|
||||
|
||||
|
||||
def _create_status(conn) -> str:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone()
|
||||
return (row[0] if row and row[0] else "draft")
|
||||
|
||||
|
||||
def _sku_exists(client: WooClient, sku: str) -> bool:
|
||||
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return len(client.get_products_by_sku(sku)) > 0
|
||||
|
||||
|
||||
def _create_product(client: WooClient, payload: dict) -> int:
|
||||
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||
return client.create_product(payload)
|
||||
|
||||
|
||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)",
|
||||
(slab_id, woo_product_id, status, None, message, _now()),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user