"""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 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 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) # 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) 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)) assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} 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) 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): return _err("stale_field", f"{field} not in current taxonomy", {"current_options": val}, status=422) 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)