693719e85f
The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.
- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
ready→publish→done); mock respects optimistic concurrency (client_rev) so
stale PUTs 409 like the real backend
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""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.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .db import get_connection
|
|
from .pricing import rate_for_species
|
|
|
|
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.
|
|
_LEAF_PATTERNS = {
|
|
"live-edge-wood": ("{figure}{species} Live Edge Slab", "Live Edge Slab", False),
|
|
"dimensional-wood": ("{figure}{species} {live_edge}Dimensional", "Dimensional", False),
|
|
"knife-blanks": ("{figure}{species} Knife Blank ({dims})", "Knife Blank", True),
|
|
"bowl-blanks": ("{figure}{species} {live_edge}Bowl Blank ({dims})", "Bowl Blank", True),
|
|
"logs-and-caps": ("{figure}{species} Log Cap", "Log Cap", False),
|
|
"pen-blanks": ("{figure}{species} Pen Blank ({dims})", "Pen Blank", True),
|
|
"pepper-mill-blanks": ("{figure}{species} Pepper Mill Blank ({dims})", "Pepper Mill Blank", True),
|
|
}
|
|
|
|
# Required fields for a slab to reach `ready` (REVIEW-5). Measurement fields
|
|
# (widths/sqft/bdft) are read-only client math but must be present.
|
|
_REQUIRED = [
|
|
"sku", "length_in", "thickness_in", "sqft", "bdft",
|
|
"width_min_in", "width_max_in", "width_avg_in",
|
|
"species_id", "wood_category_ids", "edge_type_term_id",
|
|
"grade_term_id", "thickness_term_id", "moisture_term_id",
|
|
"price", "price_source", "title", "short_description", "description",
|
|
"content_source",
|
|
]
|
|
|
|
|
|
def _error(code: str, message: str, detail: Any = None) -> dict:
|
|
return {"error": {"code": code, "message": message, "detail": detail}}
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
|
|
|
|
|
def _split_length(length_in: float) -> tuple[int, float]:
|
|
ft = int(length_in // 12)
|
|
rem = round(length_in - ft * 12, 1)
|
|
return ft, rem
|
|
|
|
|
|
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
|
|
|
|
|
|
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) -> 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["slug"] in ("dimensional-wood", "bowl-blanks"):
|
|
live_edge = "Live Edge " if slab.get("edge_type_term_id") 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 build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
|
title = build_title(slab, figure, species_name, primary_leaf)
|
|
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"
|
|
return re.sub(r"\s+", " ", line).strip()
|
|
|
|
|
|
def _slab_dict(row) -> dict:
|
|
d = dict(row)
|
|
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
|
|
if d.get(f) is not None and isinstance(d[f], str):
|
|
try:
|
|
d[f] = json.loads(d[f])
|
|
except (ValueError, TypeError):
|
|
d[f] = []
|
|
return d
|
|
|
|
|
|
def _taxonomy_lookup(conn):
|
|
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
|
|
|
|
|
|
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).
|
|
if "sku" not in missing and not sku_valid(slab.get("sku")):
|
|
missing.append("sku")
|
|
return (len(missing) == 0), missing
|
|
|
|
|
|
def price_suggestion(conn, slab: dict) -> float | None:
|
|
if not slab.get("species_id") or slab.get("bdft") is None:
|
|
return None
|
|
rate = rate_for_species(conn, slab["species_id"])
|
|
if rate is None:
|
|
return None
|
|
return round(slab["bdft"] * rate, 2)
|
|
|
|
|
|
@router.post("/{slab_id}/review")
|
|
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
|
payload = payload or {}
|
|
conn = get_connection()
|
|
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)
|
|
locked = bool(slab.get("metadata_locked"))
|
|
|
|
# --- Explicit lock (#89): owner confirms the taxonomy set; server then
|
|
# builds AND PERSISTS the deterministic title + short_description. Until
|
|
# this action runs, the prose fields stay unset (no "ready" copy from an
|
|
# unlocked AI draft). Owner prose (title/short_description) always wins.
|
|
if payload.get("action") == "lock":
|
|
if not slab.get("species_id"):
|
|
return JSONResponse(status_code=422, content=_error(
|
|
"not_ready", "Select a species before locking the metadata"))
|
|
primary_id = payload.get("primary_wood_category_id")
|
|
wood_cats = slab.get("wood_category_ids") or []
|
|
if not primary_id:
|
|
primary_id = wood_cats[0] if wood_cats else None
|
|
if not primary_id:
|
|
return JSONResponse(status_code=422, content=_error(
|
|
"not_ready", "Select a wood category before locking the metadata"))
|
|
cats, terms = _taxonomy_lookup(conn)
|
|
primary_leaf = cats.get(primary_id, {})
|
|
if not primary_leaf:
|
|
return JSONResponse(status_code=422, content=_error(
|
|
"not_ready", "Wood category is no longer in the current taxonomy"))
|
|
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
|
figure_term_ids = slab.get("figure_term_ids") or []
|
|
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
|
|
|
owner_title = (payload.get("title") or slab.get("title") or "").strip()
|
|
owner_short = (payload.get("short_description") or slab.get("short_description") or "").strip()
|
|
title = owner_title or build_title(slab, figure, species_name, primary_leaf)
|
|
short_desc = owner_short or build_short_description(slab, figure, species_name, primary_leaf)
|
|
|
|
now = _now()
|
|
cur = conn.execute(
|
|
"UPDATE slabs SET title=?, short_description=?, metadata_locked=1, server_rev=server_rev+1, updated_at=? WHERE id=?",
|
|
(title, short_desc, now, slab_id),
|
|
)
|
|
conn.commit()
|
|
if cur.rowcount == 0:
|
|
return JSONResponse(status_code=409, content=_error("not_found", "Slab not found"))
|
|
slab["title"] = title
|
|
slab["short_description"] = short_desc
|
|
slab["metadata_locked"] = 1
|
|
|
|
price = _resolve_price(conn, slab, payload)
|
|
ready, missing = compute_ready({**slab, "price": price})
|
|
return {
|
|
"ready": ready, "missing": missing, "title": title,
|
|
"short_description": short_desc, "price": price,
|
|
"price_suggestion": price_suggestion(conn, slab),
|
|
"primary_wood_category_id": primary_id, "locked": True,
|
|
}
|
|
|
|
# --- Regular review (no lock): report ready/missing + suggested prose.
|
|
# #89: deterministic title/short_description are NOT set until an explicit
|
|
# lock; report the stored values (which stay empty until locked) so the UI
|
|
# shows them as pending, and never as "ready" from an unlocked AI draft.
|
|
cats, terms = _taxonomy_lookup(conn)
|
|
primary_id = payload.get("primary_wood_category_id")
|
|
wood_cats = slab.get("wood_category_ids") or []
|
|
if not primary_id:
|
|
primary_id = wood_cats[0] if wood_cats else None
|
|
primary_leaf = cats.get(primary_id, {})
|
|
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
|
figure_term_ids = slab.get("figure_term_ids") or []
|
|
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
|
|
|
if not locked:
|
|
title = slab.get("title") or None
|
|
short_desc = slab.get("short_description") or None
|
|
else:
|
|
# Locked: re-derive only if the owner has not overridden the prose.
|
|
title = slab.get("title") or build_title(slab, figure, species_name, primary_leaf)
|
|
short_desc = slab.get("short_description") or build_short_description(slab, figure, species_name, primary_leaf)
|
|
|
|
price = _resolve_price(conn, slab, payload)
|
|
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
|
return {
|
|
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
|
|
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
|
"primary_wood_category_id": primary_id, "locked": locked,
|
|
}
|
|
|
|
|
|
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
|