Files
SlabUploader/backend/slabs.py
T
Ty 693719e85f fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
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
2026-09-20 23:36:31 -07:00

222 lines
9.4 KiB
Python

"""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
# #89: server-managed — only the explicit review lock action may set it. A plain
# PUT must not flip the lock (the deterministic copy is built inside that action).
CLIENT_FORBIDDEN_FIELDS = {"metadata_locked"}
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
# #89: expose the lock flag to the client (0/None → False, 1 → True).
d["metadata_locked"] = bool(d.get("metadata_locked"))
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)
# #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):
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)