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:
+63
-74
@@ -1,7 +1,13 @@
|
||||
"""Review to ready (S5): deterministic title/short description, price suggestion,
|
||||
and server-derived `ready` status. Cites REVIEW-5/10/15/20/25/30/40/45/50,
|
||||
METADATA-80/85/90, SETTINGS-35.
|
||||
"""Review to ready: deterministic title/short description, price suggestion,
|
||||
ready-gate. Cites REVIEW-5/10/15/20/25/30/40/45/50, METADATA-80/85/90,
|
||||
SETTINGS-35.
|
||||
|
||||
METADATA-80/85: title and short_description are assembled only from the
|
||||
confirmed (persisted) listing facts — primary wood-category leaf, species,
|
||||
figure term, and the dimension fact block. Owner-supplied values win
|
||||
(METADATA-50) via stored fields.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
@@ -18,7 +24,7 @@ router = APIRouter(prefix="/api/v1/slabs")
|
||||
|
||||
# Wood-category leaf slug -> (title pattern, noun, carries_dims).
|
||||
# Patterns interpolate {figure}, {species}, {length}, {width}, {thickness}, {bdft}.
|
||||
# Figure is Title-Case adjective; absent figure drops the slot.
|
||||
# Figure is a Title-Case adjective; absent figure drops the slot.
|
||||
_LEAF_PATTERNS = {
|
||||
"live-edge-wood": ("{figure}{species} Live Edge Slab", "Live Edge Slab", False),
|
||||
"dimensional-wood": ("{figure}{species} {live_edge}Dimensional", "Dimensional", False),
|
||||
@@ -40,6 +46,10 @@ _REQUIRED = [
|
||||
"content_source",
|
||||
]
|
||||
|
||||
# REVIEW-45: 3-24 chars, alphanumerics + hyphen. Middle-tier arbiter; the DB
|
||||
# CHECK is the backstop.
|
||||
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
@@ -56,43 +66,25 @@ def _split_length(length_in: float) -> tuple[int, float]:
|
||||
|
||||
|
||||
def _fmt1(v: float) -> str:
|
||||
# 1 dp, trim trailing .0
|
||||
s = f"{v:.1f}"
|
||||
return s[:-2] if s.endswith(".0") else s
|
||||
|
||||
|
||||
def _fmt2(v: float) -> str:
|
||||
# 2 dp, trim trailing zeros (thickness carries 1/8-inch precision)
|
||||
s = f"{v:.2f}".rstrip("0").rstrip(".")
|
||||
return s
|
||||
return f"{v:.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
|
||||
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
|
||||
|
||||
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||
)
|
||||
figure = figure.strip() if figure else ""
|
||||
live_edge = ""
|
||||
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||
# "Live Edge" is a product-form flag driven by the *synced* edge term
|
||||
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
|
||||
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
|
||||
title = pattern.format(
|
||||
figure=figure + " " if figure else "",
|
||||
species=species_name,
|
||||
live_edge=live_edge,
|
||||
noun=noun,
|
||||
dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "",
|
||||
)
|
||||
return re.sub(r"\s+", " ", title).strip()
|
||||
|
||||
|
||||
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||
"""True when the synced edge term name/slug indicates live edge."""
|
||||
"""True when the synced edge term name/slug indicates live edge.
|
||||
|
||||
METADATA-5 / #98: "Live Edge" is a product-form flag driven by the *synced*
|
||||
edge term — never any non-null edge id.
|
||||
"""
|
||||
if not edge_term_id:
|
||||
return False
|
||||
term = terms.get(edge_term_id) or {}
|
||||
@@ -100,12 +92,30 @@ def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||
return "live" in blob
|
||||
|
||||
|
||||
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
pattern, _noun, carries_dims = _LEAF_PATTERNS.get(
|
||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||
)
|
||||
figure = (figure or "").strip()
|
||||
live_edge = ""
|
||||
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
|
||||
title = pattern.format(
|
||||
figure=figure + " " if figure else "",
|
||||
species=species_name,
|
||||
live_edge=live_edge,
|
||||
dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "",
|
||||
)
|
||||
return re.sub(r"\s+", " ", title).strip()
|
||||
|
||||
|
||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
|
||||
title = build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
ft, rem = _split_length(slab["length_in"])
|
||||
length_str = f"{ft}ft {_fmt1(rem)}in"
|
||||
bdft = round(slab["bdft"], 1)
|
||||
line = f"{title}, {length_str} x {_fmt1(slab['width_avg_in'])}in x {_fmt2(slab['thickness_in'])}in, {_fmt1(bdft)} bdft"
|
||||
line = (
|
||||
f"{title}, {ft}ft {_fmt1(rem)}in x {_fmt1(slab['width_avg_in'])}in x "
|
||||
f"{_fmt2(slab['thickness_in'])}in, {_fmt1(round(slab['bdft'], 1))} bdft"
|
||||
)
|
||||
return re.sub(r"\s+", " ", line).strip()
|
||||
|
||||
|
||||
@@ -120,7 +130,7 @@ def _slab_dict(row) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def _taxonomy_lookup(conn):
|
||||
def _taxonomy_lookup(conn) -> tuple[dict, dict]:
|
||||
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
|
||||
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
|
||||
return cats, terms
|
||||
@@ -130,21 +140,16 @@ def missing_required(slab: dict) -> list[str]:
|
||||
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
|
||||
|
||||
|
||||
# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier
|
||||
# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop.
|
||||
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
|
||||
|
||||
|
||||
def sku_valid(sku: Any) -> bool:
|
||||
return isinstance(sku, str) and bool(_SKU_RE.match(sku))
|
||||
|
||||
|
||||
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
|
||||
missing = missing_required(slab)
|
||||
# A present-but-malformed SKU is not ready (business rule, enforced in the middle tier).
|
||||
# A present-but-malformed SKU is not ready (business rule, middle tier).
|
||||
if "sku" not in missing and not sku_valid(slab.get("sku")):
|
||||
missing.append("sku")
|
||||
return (len(missing) == 0), missing
|
||||
return len(missing) == 0, missing
|
||||
|
||||
|
||||
def price_suggestion(conn, slab: dict) -> float | None:
|
||||
@@ -156,32 +161,33 @@ def price_suggestion(conn, slab: dict) -> float | None:
|
||||
return round(slab["bdft"] * rate, 2)
|
||||
|
||||
|
||||
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||
else the stored price, else the suggestion (None if no rate)."""
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
return payload["price"]
|
||||
if slab.get("price") not in (None, 0):
|
||||
return slab["price"]
|
||||
return suggested
|
||||
|
||||
|
||||
@router.post("/{slab_id}/review")
|
||||
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
||||
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
|
||||
"""Compute ready + deterministic title/short_description from *locked*
|
||||
taxonomy (#89: the Create button's source of truth).
|
||||
|
||||
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
|
||||
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
|
||||
win (METADATA-50). Assembled values are written back to the slab so the lock
|
||||
sticks for publish.
|
||||
Owner overrides (price/sku) from the payload are persisted; title and
|
||||
short_description are produced by the deterministic build and are NOT
|
||||
persisted from the payload.
|
||||
"""
|
||||
payload = payload or {}
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
slab = _slab_dict(row)
|
||||
|
||||
# --- Deterministic review: report ready/missing + the deterministic
|
||||
# title/short_description built from the current metadata. The client's
|
||||
# "Create title" / "Create short desc" buttons call this endpoint and write the
|
||||
# returned strings into the fields (#89: no separate lock action).
|
||||
# #80: persist owner overrides (price/sku) from the payload so the review
|
||||
# endpoint is self-contained. title/short_description are NOT overridden here —
|
||||
# they are produced by the deterministic build below, not persisted from the
|
||||
# payload (so the review POST can't clobber the owner's Create workflow).
|
||||
cats, terms = _taxonomy_lookup(conn)
|
||||
primary_id = payload.get("primary_wood_category_id")
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
@@ -192,11 +198,8 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
figure_term_ids = slab.get("figure_term_ids") or []
|
||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||
|
||||
# #80: persist owner overrides from the payload (price/sku only).
|
||||
overrides: dict[str, Any] = {}
|
||||
for k in ("price", "sku"):
|
||||
if k in payload and payload[k] is not None:
|
||||
overrides[k] = payload[k]
|
||||
# #80: persist owner overrides (price/sku only) so the review is self-contained.
|
||||
overrides = {k: payload[k] for k in ("price", "sku") if payload.get(k) is not None}
|
||||
if overrides:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in overrides)
|
||||
conn.execute(
|
||||
@@ -206,9 +209,6 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
conn.commit()
|
||||
slab.update(overrides)
|
||||
|
||||
# Deterministic title / short_description, re-derived from the current
|
||||
# metadata on every review (the Create button's source of truth).
|
||||
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
|
||||
title = (
|
||||
build_title(slab, figure, species_name, primary_leaf, terms)
|
||||
if (primary_leaf and species_name)
|
||||
@@ -227,14 +227,3 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
||||
"primary_wood_category_id": primary_id,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||
else the stored price, else the suggestion (None if no rate)."""
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
return payload["price"]
|
||||
if slab.get("price") not in (None, 0):
|
||||
return slab["price"]
|
||||
return suggested
|
||||
|
||||
Reference in New Issue
Block a user