"""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 _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) # Apply any review edits (primary category declaration + prose). editable = { "primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source", } for k in editable: if k in payload and payload[k] is not None: if k == "primary_wood_category_id": continue # not stored as a column; derived slab[k] = payload[k] cats, terms = _taxonomy_lookup(conn) # Primary wood category: declared, else first wood_category_ids, else first category. 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 # Taxonomy lock gate (#89): only assemble title/short_desc when the owner has # confirmed the facts that patterns need (species + primary wood category). taxonomy_locked = bool(primary_leaf and species_name and primary_leaf.get("slug")) # Owner override: non-empty title/short_description in the payload wins. # Empty/absent means "assemble from locked taxonomy". raw_title = payload.get("title") raw_short = payload.get("short_description") want_title = not (isinstance(raw_title, str) and raw_title.strip()) want_short = not (isinstance(raw_short, str) and raw_short.strip()) if taxonomy_locked: if want_title: slab["title"] = build_title(slab, figure, species_name, primary_leaf, terms) if want_short: slab["short_description"] = build_short_description( slab, figure, species_name, primary_leaf, terms ) # Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks. suggested = price_suggestion(conn, slab) if payload.get("price") is not None: price = payload["price"] elif slab.get("price") not in (None, 0): price = slab["price"] else: price = suggested if price is not None: slab["price"] = price if not slab.get("price_source"): slab["price_source"] = "recommendation" # Persist lock: write assembled title/short_desc/price onto the slab so publish # and subsequent loads see the confirmed set (#89). now = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() conn.execute( "UPDATE slabs SET title=?, short_description=?, price=?, price_source=COALESCE(price_source, ?), " "updated_at=? WHERE id=?", ( slab.get("title"), slab.get("short_description"), price, "recommendation", now, slab_id, ), ) # Server-derived ready status on the row when the full set is present. ready, missing = compute_ready({**slab, "price": price}) if ready: conn.execute("UPDATE slabs SET status='ready', updated_at=? WHERE id=? AND status NOT IN ('published','failed')", (now, slab_id)) conn.commit() return { "ready": ready, "missing": missing, "title": slab.get("title"), "short_description": slab.get("short_description"), "price": price, "price_suggestion": suggested, "primary_wood_category_id": primary_id, "taxonomy_locked": taxonomy_locked, }