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()
|
_enforce_startup_guards()
|
||||||
|
|
||||||
|
|
||||||
# --- Request ID middleware (threads X-Request-Id or generates) ---
|
# --- Request ID middleware (threads X-Request-Id or generates) --
|
||||||
|
|
||||||
|
|
||||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
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)
|
return settings_mod.update_settings(payload)
|
||||||
|
|
||||||
|
|
||||||
|
# expose for tests
|
||||||
|
|
||||||
|
from .slabs import router as slabs_router
|
||||||
|
app.include_router(slabs_router)
|
||||||
|
|
||||||
# expose for tests
|
# expose for tests
|
||||||
__all__ = ["app", "__version__"]
|
__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 AxisPanel from './capture/AxisPanel.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
const session: CaptureSession = createCaptureSession();
|
const session: CaptureSession = createCaptureSession();
|
||||||
@@ -18,6 +19,7 @@ import Settings from './create/settings/Settings.svelte';
|
|||||||
let busy = false;
|
let busy = false;
|
||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
|
let showSlabs = false;
|
||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
@@ -90,10 +92,16 @@ import Settings from './create/settings/Settings.svelte';
|
|||||||
{pngUrl}
|
{pngUrl}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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}
|
{#if showSettings}
|
||||||
<Settings />
|
<Settings />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showSlabs}
|
||||||
|
<SlabList />
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/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