feat(slabs): S2 CRUD + optimistic concurrency + soft delete + slab list UI
- backend/slabs.py: create (calibrated, client-derived stored as sent), list (excl soft-deleted, most recent), get, update (single compare-and-set on server_rev, 409 revision_conflict / 409 duplicate_sku / 422 stale_field, status server-derived), delete (soft, published rejected). Explicit column allowlists, parameterized SQL, JSON array fields, commits. - backend/app.py: wire slabs router. - frontend: src/api/slabs.ts client + create/slabs/SlabList.svelte + dev toggle. - tests: 9 slabs tests (create/dup/list/get/update/rev-conflict/dup-sku/soft-delete/ published-delete) with full taxonomy seeding. Verified: pytest 23 passed; check_requirements + check_coverage exit 0; npm build ok.
This commit is contained in:
+8
-1
@@ -90,7 +90,9 @@ def _enforce_startup_guards() -> None:
|
||||
_enforce_startup_guards()
|
||||
|
||||
|
||||
# --- Request ID middleware (threads X-Request-Id or generates) ---
|
||||
# --- Request ID middleware (threads X-Request-Id or generates) --
|
||||
|
||||
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
@@ -178,5 +180,10 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return settings_mod.update_settings(payload)
|
||||
|
||||
|
||||
# expose for tests
|
||||
|
||||
from .slabs import router as slabs_router
|
||||
app.include_router(slabs_router)
|
||||
|
||||
# expose for tests
|
||||
__all__ = ["app", "__version__"]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""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
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
missing = [f for f in ALLOWED_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)
|
||||
cols = [f for f in ALLOWED_FIELDS if f in data] + ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
|
||||
vals = [data[f] for f in cols if f in data] + ["calibrated", 1, 1, None, now, now]
|
||||
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)
|
||||
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)
|
||||
@@ -0,0 +1,192 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
# Helper to set environment for tests
|
||||
|
||||
def _setup_env(monkeypatch, tmp):
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
img_dir = os.path.join(tmp, "images")
|
||||
os.mkdir(img_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "aGVsbGxvb2xvY2g=") # dummy base64
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
# Taxonomy seeding helper
|
||||
|
||||
def _seed_taxonomy(conn):
|
||||
# create some categories
|
||||
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (1, 'Species A', 'species-a', NULL, 10)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (2, 'Wood Cat A', 'wood-cat-a', NULL, 20)")
|
||||
# attribute terms
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (1, 'Edge Type', 'edge-type', 30)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (1, 1, 'Edge A', 'edge-a', 40)")
|
||||
# tags
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
|
||||
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
|
||||
conn.commit()
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
conn = db_mod.get_connection()
|
||||
db_mod.init_db()
|
||||
_seed_taxonomy(conn)
|
||||
return conn
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
conn = db_mod.get_connection()
|
||||
db_mod.init_db()
|
||||
_seed_taxonomy(conn)
|
||||
return conn
|
||||
|
||||
# Helper to get client
|
||||
@pytest.fixture
|
||||
def client(db):
|
||||
from backend.app import app
|
||||
return TestClient(app)
|
||||
|
||||
# Utility to format now
|
||||
_now_iso = lambda: datetime.datetime.utcnow().replace(microsecond=0).isoformat()
|
||||
|
||||
# --- Tests ---
|
||||
|
||||
|
||||
def test_create_and_fetch(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"id": slab_id,
|
||||
"sku": "SKU001",
|
||||
"length_in": 10.0,
|
||||
"thickness_in": 2.0,
|
||||
"sqft": 20.0,
|
||||
"bdft": 5.0,
|
||||
"width_min_in": 1.0,
|
||||
"width_max_in": 2.0,
|
||||
"width_avg_in": 1.5,
|
||||
"species_id": 1,
|
||||
"wood_category_ids": json.dumps([2]),
|
||||
"edge_type_term_id": 1,
|
||||
"figure_term_ids": json.dumps([1]),
|
||||
"grade_term_id": 1,
|
||||
"thickness_term_id": 1,
|
||||
"moisture_term_id": 1,
|
||||
"fig_tag_ids": json.dumps([1]),
|
||||
"feat_tag_ids": json.dumps([2]),
|
||||
"price": 100.0,
|
||||
"price_source": "recommendation",
|
||||
"title": "Test Slab",
|
||||
"short_description": "short",
|
||||
"description": "desc",
|
||||
"content_source": "manual",
|
||||
"species_confidence": 0.9,
|
||||
"figure_confidence": 0.8,
|
||||
"grade_confidence": 0.7,
|
||||
"inference_status": "inferring",
|
||||
"calibration_photo_id": None
|
||||
}
|
||||
resp = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["status"] == "calibrated"
|
||||
assert body["client_rev"] == 1
|
||||
assert body["server_rev"] == 1
|
||||
for key in ["length_in","thickness_in","sqft","bdft","width_min_in","width_max_in","width_avg_in"]:
|
||||
assert body[key] == payload[key]
|
||||
|
||||
|
||||
def test_duplicate_sku(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
resp1 = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp1.status_code == 201
|
||||
resp2 = client.post("/api/v1/slabs", json=payload)
|
||||
assert resp2.status_code == 409
|
||||
|
||||
|
||||
def test_list_and_exclude_deleted(client, db):
|
||||
# create two slabs
|
||||
for i in [1,2]:
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": f"SKU{i}", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# delete first created
|
||||
resp_list = client.get("/api/v1/slabs")
|
||||
ids = [r["id"] for r in resp_list.json()]
|
||||
client.delete(f"/api/v1/slabs/{ids[0]}")
|
||||
# list again
|
||||
resp2 = client.get("/api/v1/slabs")
|
||||
body = resp2.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["id"] == ids[1]
|
||||
|
||||
|
||||
def test_get_not_found(client, db):
|
||||
resp = client.get("/api/v1/slabs/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_update_success(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# update
|
||||
update_payload = {"client_rev":1, "sku":"SKU002"}
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json=update_payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["sku"] == "SKU002"
|
||||
assert body["server_rev"] == 2
|
||||
|
||||
|
||||
def test_update_revision_conflict(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# wrong client_rev
|
||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev":99, "sku":"SKU002"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_update_duplicate_sku(client, db):
|
||||
# create two slabs
|
||||
id1, id2 = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
payload1 = {"id": id1, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
payload2 = {"id": id2, "sku": "SKU002", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload1)
|
||||
client.post("/api/v1/slabs", json=payload2)
|
||||
# update first to sku of second
|
||||
resp = client.put(f"/api/v1/slabs/{id1}", json={"client_rev":1, "sku":"SKU002"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_soft_delete(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
client.delete(f"/api/v1/slabs/{slab_id}")
|
||||
resp = client.get("/api/v1/slabs")
|
||||
body = resp.json()
|
||||
assert all(r["id"] != slab_id for r in body)
|
||||
resp_get = client.get(f"/api/v1/slabs/{slab_id}")
|
||||
assert resp_get.status_code == 404
|
||||
|
||||
|
||||
def test_delete_published_conflict(client, db):
|
||||
slab_id = str(uuid.uuid4())
|
||||
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
|
||||
client.post("/api/v1/slabs", json=payload)
|
||||
# manually set status to published
|
||||
conn = db_mod.get_connection()
|
||||
conn.execute("UPDATE slabs SET status = 'published' WHERE id = ?", (slab_id,))
|
||||
conn.commit()
|
||||
resp = client.delete(f"/api/v1/slabs/{slab_id}")
|
||||
assert resp.status_code == 409
|
||||
@@ -10,6 +10,7 @@ import MaskPanel from './capture/MaskPanel.svelte';
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
@@ -18,6 +19,7 @@ import Settings from './create/settings/Settings.svelte';
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
let showSettings = false;
|
||||
let showSlabs = false;
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
@@ -90,10 +92,16 @@ import Settings from './create/settings/Settings.svelte';
|
||||
{pngUrl}
|
||||
/>
|
||||
{/if}
|
||||
<button on:click={() => (showSettings = !showSettings)} style="margin-top:1rem">Settings (dev)</button>
|
||||
<div style="margin-top:1rem;display:flex;gap:8px">
|
||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
||||
</div>
|
||||
{#if showSettings}
|
||||
<Settings />
|
||||
{/if}
|
||||
{#if showSlabs}
|
||||
<SlabList />
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
|
||||
|
||||
export interface Slab {
|
||||
id: string;
|
||||
sku: string;
|
||||
status: string;
|
||||
price: number;
|
||||
length_in: number;
|
||||
thickness_in: number;
|
||||
sqft: number;
|
||||
bdft: number;
|
||||
width_avg_in: number;
|
||||
title: string | null;
|
||||
server_rev: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string | null, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let code: string | null = null;
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) {
|
||||
code = body.error.code ?? null;
|
||||
message = body.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function listSlabs(): Promise<Slab[]> {
|
||||
return fetch('/api/v1/slabs').then((r) => parse<Slab[]>(r));
|
||||
}
|
||||
|
||||
export function getSlab(id: string): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listSlabs, type Slab } from '../../api/slabs';
|
||||
|
||||
let slabs: Slab[] = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
slabs = await listSlabs();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load slabs';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="slab-list">
|
||||
<div class="head">
|
||||
<h2>Slabs</h2>
|
||||
<button on:click={() => void load()}>Refresh</button>
|
||||
</div>
|
||||
{#if loading}
|
||||
<p>Loading…</p>
|
||||
{:else if error}
|
||||
<p class="err">{error}</p>
|
||||
{:else if slabs.length === 0}
|
||||
<p class="empty">No slabs yet.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Size (L×T)</th>
|
||||
<th>Avg W</th>
|
||||
<th>sqft</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each slabs as s (s.id)}
|
||||
<tr>
|
||||
<td>{s.sku}</td>
|
||||
<td>{s.length_in}″ × {s.thickness_in}″</td>
|
||||
<td>{s.width_avg_in}″</td>
|
||||
<td>{s.sqft}</td>
|
||||
<td>${s.price.toFixed(2)}</td>
|
||||
<td><span class="status" class:ready={s.status === 'ready'}>{s.status}</span></td>
|
||||
<td>{s.updated_at?.replace('T', ' ').slice(0, 16)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.slab-list { max-width: 720px; color: #eee; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #333; font-size: 0.9rem; }
|
||||
.status { text-transform: capitalize; }
|
||||
.status.ready { color: #7fd37f; }
|
||||
.err { color: #e88; }
|
||||
.empty { color: #888; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user