Files
SlabUploader/backend/review.py
T
Ty 0c00d25cc7 fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98
- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
2026-09-21 12:55:17 -07:00

241 lines
9.7 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, 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."""
if not edge_term_id:
return False
term = terms.get(edge_term_id) or {}
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
return "live" in blob
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"
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:
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
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.
"""
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)
# --- 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 []
if not primary_id:
primary_id = wood_cats[0] if wood_cats else None
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
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]
if overrides:
set_clause = ", ".join(f"{k} = ?" for k in overrides)
conn.execute(
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
[*overrides.values(), _now(), slab_id],
)
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)
else slab.get("title") or None
)
short_desc = (
build_short_description(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("short_description") or None
)
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,
}
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