diff --git a/backend/app.py b/backend/app.py index 5beff88..743b9a3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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__"] diff --git a/backend/slabs.py b/backend/slabs.py new file mode 100644 index 0000000..814c5b3 --- /dev/null +++ b/backend/slabs.py @@ -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) diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py new file mode 100644 index 0000000..b22fb14 --- /dev/null +++ b/backend/tests/test_slabs_api.py @@ -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 diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a8a359c..137fe25 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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} - +
Loading…
+ {:else if error} +{error}
+ {:else if slabs.length === 0} +No slabs yet.
+ {:else} +| SKU | +Size (L×T) | +Avg W | +sqft | +Price | +Status | +Updated | +
|---|---|---|---|---|---|---|
| {s.sku} | +{s.length_in}″ × {s.thickness_in}″ | +{s.width_avg_in}″ | +{s.sqft} | +${s.price.toFixed(2)} | +{s.status} | +{s.updated_at?.replace('T', ' ').slice(0, 16)} | +