"""Slab CRUD service + FastAPI router (S2). Client-derived numbers are stored as sent (never recomputed). Optimistic concurrency via a single compare-and-set UPDATE on server_rev. Soft delete sets deleted_at; published slabs cannot be deleted. """ import json from datetime import datetime, timezone from typing import Any, Dict, List from fastapi import APIRouter from fastapi.responses import JSONResponse, Response from .db import get_connection from .thickness_band import load_thickness_terms, resolve_thickness_band_id router = APIRouter(prefix="/api/v1/slabs") # Client-settable slab columns (explicit allowlist; never build SQL from client keys). ALLOWED_FIELDS = [ "id", "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", "figure_term_ids", "grade_term_id", "thickness_term_id", "moisture_term_id", "fig_tag_ids", "feat_tag_ids", "price", "price_source", "title", "short_description", "description", "content_source", "species_confidence", "figure_confidence", "grade_confidence", "inference_status", "calibration_photo_id", ] JSON_ARRAY_FIELDS = {"wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"} # Columns a client may set on update (id/status/revs/timestamps/deleted_at excluded). ALLOWED_UPDATE_FIELDS = set(ALLOWED_FIELDS) - { "id", "status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at", } TAXONOMY_TABLES = { "species_id": "woo_categories", "wood_category_ids": "woo_categories", "edge_type_term_id": "woo_attribute_terms", "figure_term_ids": "woo_attribute_terms", "grade_term_id": "woo_attribute_terms", "thickness_term_id": "woo_attribute_terms", "moisture_term_id": "woo_attribute_terms", "fig_tag_ids": "woo_tags", "feat_tag_ids": "woo_tags", } def _now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() def _error(code: str, message: str, detail: Any = None) -> Dict[str, Any]: return {"error": {"code": code, "message": message, "detail": detail}} def _err(code: str, message: str, detail: Any = None, status: int = 400) -> JSONResponse: return JSONResponse(status_code=status, content=_error(code, message, detail)) def _encode(payload: Dict[str, Any]) -> Dict[str, Any]: out = dict(payload) for f in JSON_ARRAY_FIELDS: if f in out and isinstance(out[f], list): out[f] = json.dumps(out[f]) return out # #89: server-managed fields that a plain client PUT must not set directly. # (metadata_locked was dropped — the deterministic title/short-desc are built by # the review endpoint and written back by the client's Create buttons.) CLIENT_FORBIDDEN_FIELDS = set() def _row_to_slab(row) -> Dict[str, Any]: d = dict(row) for f in JSON_ARRAY_FIELDS: if d.get(f) is not None and isinstance(d[f], str): try: d[f] = json.loads(d[f]) except (ValueError, TypeError): pass return d def _sku_taken(conn, sku: str, exclude_id: str | None = None) -> bool: if exclude_id: return conn.execute( "SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL AND id<>?", (sku, exclude_id) ).fetchone() is not None return conn.execute( "SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL", (sku,) ).fetchone() is not None def _check_taxonomy(conn, table: str, val: Any) -> bool: if isinstance(val, list): if not val: return True ph = ",".join(["?"] * len(val)) rows = conn.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", val).fetchall() return {r[0] for r in rows} == set(val) return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None # Capture-only fields required to create a slab. The Review screen fills the # taxonomy/content fields after create (CREATE.md §1: "every other field stays # editable until create"), so create must accept a capture-math payload. # client_rev is server-managed (set to 1 at create) — never required from the client. REQUIRED_CREATE_FIELDS = [ "id", "sku", "length_in", "thickness_in", "sqft", "bdft", "width_min_in", "width_max_in", "width_avg_in", ] @router.post("", status_code=201) async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]: missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload] if missing: return _err("validation", "missing fields", {"fields": missing}, status=400) # #83: reject unknown keys (client sent a field that is not client-settable). # The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped # — but a 400 tells the client its payload is malformed, not that the slab # was created with the extra keys silently ignored. unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"] if unknown: return _err("validation", "unknown fields", {"fields": unknown}, status=400) # Business rule (middle tier, not the DB): an empty/blank SKU at create means # "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only # governs present SKUs, and NULL is the honest "absent" representation. sku = payload["sku"] if isinstance(sku, str) and sku.strip() == "": payload = {**payload, "sku": None} conn = get_connection() if payload["sku"] is not None and _sku_taken(conn, payload["sku"]): return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409) # METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen. # Resolve it from the measured thickness against the live synced Woo bands and # overwrite whatever the client sent (the UI has no band control). band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn)) if band is not None: payload = {**payload, "thickness_term_id": band} now = _now_iso() data = _encode(payload) allowed_cols = [f for f in ALLOWED_FIELDS if f in data] server_cols = ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"] server_vals = ["calibrated", 1, 1, None, now, now] cols = allowed_cols + server_cols # One value per col, in cols order: data values for the allowed fields, then the 6 server values. vals = [data[f] for f in allowed_cols] + server_vals ph = ",".join(["?"] * len(cols)) conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals) conn.commit() row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone() return _row_to_slab(row) @router.get("") async def list_slabs() -> List[Dict[str, Any]]: conn = get_connection() rows = conn.execute("SELECT * FROM slabs WHERE deleted_at IS NULL ORDER BY updated_at DESC, id DESC").fetchall() return [_row_to_slab(r) for r in rows] @router.get("/{id}") async def get_slab(id: str) -> Dict[str, Any]: conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if row is None: return _err("not_found", "Slab not found", status=404) return _row_to_slab(row) @router.put("/{id}") async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]: if "client_rev" not in payload: return _err("validation", "client_rev required", status=400) # #89: the metadata lock is server-managed via the explicit review lock # action; a plain PUT may not flip it. for field in CLIENT_FORBIDDEN_FIELDS: if field in payload: return _err("validation", f"{field} is set by the review lock action, not by update", status=400) conn = get_connection() current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if current is None: return _err("not_found", "Slab not found", status=404) client_rev = payload["client_rev"] if client_rev != current["server_rev"]: return _err("revision_conflict", "client_rev mismatch", {"current_server_rev": current["server_rev"]}, status=409) if "sku" in payload and payload["sku"] != current["sku"]: if _sku_taken(conn, payload["sku"], exclude_id=id): return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409) for field, table in TAXONOMY_TABLES.items(): if field in payload and payload[field] is not None: val = payload[field] if isinstance(val, str): try: val = json.loads(val) except (ValueError, TypeError): val = [] if not _check_taxonomy(conn, table, val): # #82: return the LIVE taxonomy options (valid ids in the table), # not the submitted value. The client needs to know what's valid. if isinstance(val, list): live = [r[0] for r in conn.execute( f"SELECT id FROM {table} ORDER BY id" ).fetchall()] else: live = [val] # single-id field: just show what was submitted return _err("stale_field", f"{field} not in current taxonomy", {"field": field, "submitted": val, "current_options": live}, status=422) eff_thickness = payload.get("thickness_in", current["thickness_in"]) band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn)) if band is not None: payload = {**payload, "thickness_term_id": band} data = _encode(payload) cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data] if not cols: return _row_to_slab(current) set_clause = ", ".join(f"{f} = ?" for f in cols) vals = [data[f] for f in cols] + [client_rev + 1, _now_iso(), id, client_rev] cur = conn.execute( f"UPDATE slabs SET {set_clause}, server_rev=?, updated_at=? WHERE id=? AND server_rev=?", vals, ) conn.commit() if cur.rowcount == 0: cur_server = conn.execute("SELECT server_rev FROM slabs WHERE id=?", (id,)).fetchone() return _err("revision_conflict", "client_rev mismatch", {"current_server_rev": cur_server[0] if cur_server else None}, status=409) row = conn.execute("SELECT * FROM slabs WHERE id=?", (id,)).fetchone() return _row_to_slab(row) @router.delete("/{id}", status_code=204) async def delete_slab(id: str): conn = get_connection() row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone() if row is None: return _err("not_found", "Slab not found", status=404) if row["status"] == "published": return _err("publish_conflict", "Published slab cannot be deleted", status=409) conn.execute("UPDATE slabs SET deleted_at=? WHERE id=?", (_now_iso(), id)) conn.commit() return Response(status_code=204)