c8d6d5eda0
create_slab required ALL ALLOWED_FIELDS present, but the Review screen fills taxonomy/content after create (CREATE.md §1). Now requires only the capture fields (id, sku, dims, widths); the rest stay nullable for Review. Also fixed a pre-existing off-by-one: vals re-filtered cols by 'in data', dropping the 6 server-managed columns and under-counting bindings (19 vs 18). Split allowed_cols/server_cols so vals always matches cols. Verified: 9/9 slabs tests pass; live POST /slabs with the app's capture-math payload returns 201.
204 lines
8.4 KiB
Python
204 lines
8.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
|
|
|
|
|
|
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)
|
|
conn = get_connection()
|
|
if _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)
|