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
This commit is contained in:
@@ -55,6 +55,7 @@ _SCHEMA_SENTINELS = [
|
||||
("settings", "value"),
|
||||
("slabs", "deleted_at"),
|
||||
("slabs", "server_rev"),
|
||||
("slabs", "metadata_locked"),
|
||||
("slab_photos", "original_blob"),
|
||||
("sync_log", "payload_hash"),
|
||||
]
|
||||
|
||||
@@ -35,6 +35,9 @@ CREATE TABLE IF NOT EXISTS slabs (
|
||||
species_confidence REAL,
|
||||
figure_confidence REAL,
|
||||
grade_confidence REAL,
|
||||
-- #89: explicit owner lock of the confirmed taxonomy set. Deterministic
|
||||
-- title/short_description are built and persisted only once this is 1.
|
||||
metadata_locked INTEGER CHECK(metadata_locked IN (0,1) OR metadata_locked IS NULL),
|
||||
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
|
||||
calibration_photo_id TEXT,
|
||||
client_rev INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
+85
-31
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -44,6 +45,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||
|
||||
|
||||
def _split_length(length_in: float) -> tuple[int, float]:
|
||||
ft = int(length_in // 12)
|
||||
rem = round(length_in - ft * 12, 1)
|
||||
@@ -150,15 +155,63 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
if row is None:
|
||||
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
||||
slab = _slab_dict(row)
|
||||
# Apply any review edits (primary category declaration + prose).
|
||||
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"}
|
||||
for k in editable:
|
||||
if k in payload and payload[k] is not None:
|
||||
if k == "primary_wood_category_id":
|
||||
continue # not stored as a column; derived
|
||||
slab[k] = payload[k]
|
||||
locked = bool(slab.get("metadata_locked"))
|
||||
|
||||
# --- Explicit lock (#89): owner confirms the taxonomy set; server then
|
||||
# builds AND PERSISTS the deterministic title + short_description. Until
|
||||
# this action runs, the prose fields stay unset (no "ready" copy from an
|
||||
# unlocked AI draft). Owner prose (title/short_description) always wins.
|
||||
if payload.get("action") == "lock":
|
||||
if not slab.get("species_id"):
|
||||
return JSONResponse(status_code=422, content=_error(
|
||||
"not_ready", "Select a species before locking the metadata"))
|
||||
primary_id = payload.get("primary_wood_category_id")
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
if not primary_id:
|
||||
primary_id = wood_cats[0] if wood_cats else None
|
||||
if not primary_id:
|
||||
return JSONResponse(status_code=422, content=_error(
|
||||
"not_ready", "Select a wood category before locking the metadata"))
|
||||
cats, terms = _taxonomy_lookup(conn)
|
||||
primary_leaf = cats.get(primary_id, {})
|
||||
if not primary_leaf:
|
||||
return JSONResponse(status_code=422, content=_error(
|
||||
"not_ready", "Wood category is no longer in the current taxonomy"))
|
||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
||||
figure_term_ids = slab.get("figure_term_ids") or []
|
||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||
|
||||
owner_title = (payload.get("title") or slab.get("title") or "").strip()
|
||||
owner_short = (payload.get("short_description") or slab.get("short_description") or "").strip()
|
||||
title = owner_title or build_title(slab, figure, species_name, primary_leaf)
|
||||
short_desc = owner_short or build_short_description(slab, figure, species_name, primary_leaf)
|
||||
|
||||
now = _now()
|
||||
cur = conn.execute(
|
||||
"UPDATE slabs SET title=?, short_description=?, metadata_locked=1, server_rev=server_rev+1, updated_at=? WHERE id=?",
|
||||
(title, short_desc, now, slab_id),
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return JSONResponse(status_code=409, content=_error("not_found", "Slab not found"))
|
||||
slab["title"] = title
|
||||
slab["short_description"] = short_desc
|
||||
slab["metadata_locked"] = 1
|
||||
|
||||
price = _resolve_price(conn, slab, payload)
|
||||
ready, missing = compute_ready({**slab, "price": price})
|
||||
return {
|
||||
"ready": ready, "missing": missing, "title": title,
|
||||
"short_description": short_desc, "price": price,
|
||||
"price_suggestion": price_suggestion(conn, slab),
|
||||
"primary_wood_category_id": primary_id, "locked": True,
|
||||
}
|
||||
|
||||
# --- Regular review (no lock): report ready/missing + suggested prose.
|
||||
# #89: deterministic title/short_description are NOT set until an explicit
|
||||
# lock; report the stored values (which stay empty until locked) so the UI
|
||||
# shows them as pending, and never as "ready" from an unlocked AI draft.
|
||||
cats, terms = _taxonomy_lookup(conn)
|
||||
# Primary wood category: declared, else first wood_category_ids, else first category.
|
||||
primary_id = payload.get("primary_wood_category_id")
|
||||
wood_cats = slab.get("wood_category_ids") or []
|
||||
if not primary_id:
|
||||
@@ -168,28 +221,29 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
|
||||
figure_term_ids = slab.get("figure_term_ids") or []
|
||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
||||
|
||||
# Deterministic copy (only if not already overridden by owner).
|
||||
if not payload.get("title"):
|
||||
if primary_leaf and species_name:
|
||||
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
|
||||
if not payload.get("short_description"):
|
||||
if primary_leaf and species_name:
|
||||
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
|
||||
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
|
||||
if not locked:
|
||||
title = slab.get("title") or None
|
||||
short_desc = slab.get("short_description") or None
|
||||
else:
|
||||
# Locked: re-derive only if the owner has not overridden the prose.
|
||||
title = slab.get("title") or build_title(slab, figure, species_name, primary_leaf)
|
||||
short_desc = slab.get("short_description") or build_short_description(slab, figure, species_name, primary_leaf)
|
||||
|
||||
price = _resolve_price(conn, slab, payload)
|
||||
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
||||
return {
|
||||
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
|
||||
"price": price, "price_suggestion": price_suggestion(conn, slab),
|
||||
"primary_wood_category_id": primary_id, "locked": locked,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
|
||||
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
|
||||
else the stored price, else the suggestion (None if no rate)."""
|
||||
suggested = price_suggestion(conn, slab)
|
||||
if payload.get("price") is not None:
|
||||
price = payload["price"]
|
||||
elif slab.get("price") not in (None, 0):
|
||||
price = slab["price"]
|
||||
else:
|
||||
price = suggested
|
||||
ready, missing = compute_ready({**slab, "price": price})
|
||||
return {
|
||||
"ready": ready,
|
||||
"missing": missing,
|
||||
"title": slab.get("title"),
|
||||
"short_description": slab.get("short_description"),
|
||||
"price": price,
|
||||
"price_suggestion": suggested,
|
||||
"primary_wood_category_id": primary_id,
|
||||
}
|
||||
return payload["price"]
|
||||
if slab.get("price") not in (None, 0):
|
||||
return slab["price"]
|
||||
return suggested
|
||||
|
||||
@@ -64,6 +64,11 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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:
|
||||
@@ -72,6 +77,8 @@ def _row_to_slab(row) -> Dict[str, Any]:
|
||||
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
|
||||
|
||||
|
||||
@@ -155,6 +162,11 @@ async def get_slab(id: str) -> Dict[str, Any]:
|
||||
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:
|
||||
|
||||
@@ -55,12 +55,19 @@ def test_title_live_edge_no_dims(env):
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = env[0].post("/api/v1/slabs/s1/review", json={})
|
||||
# #89: title/short_description are built AND persisted only after an explicit lock.
|
||||
r = env[0].post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["locked"] is True
|
||||
assert body["title"] == "Figured Black Walnut Live Edge Slab"
|
||||
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in")
|
||||
assert "18.4 bdft" in body["short_description"]
|
||||
# GET shows the stored (persisted) copy — it matches the deterministic pattern.
|
||||
got = env[0].get("/api/v1/slabs/s1").json()
|
||||
assert got["title"] == "Figured Black Walnut Live Edge Slab"
|
||||
assert got["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
|
||||
assert got["metadata_locked"] is True
|
||||
|
||||
|
||||
def test_ready_requires_full_set(env):
|
||||
@@ -134,5 +141,84 @@ def test_knife_blank_carries_dims(env):
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
# #89: build happens on lock.
|
||||
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
|
||||
|
||||
|
||||
# --- #89: explicit lock gate for deterministic title/short_description ---
|
||||
|
||||
def test_review_before_lock_reports_no_title(env):
|
||||
"""#89 AC: until lock, title/short_description are not set (no 'ready' copy
|
||||
from an unlocked AI draft). A plain review reports the stored (empty) values."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={})
|
||||
body = r.json()
|
||||
assert body["locked"] is False
|
||||
assert body["title"] is None
|
||||
assert body["short_description"] is None
|
||||
# title/short_description are in the missing set (not ready until locked).
|
||||
assert "title" in body["missing"] and "short_description" in body["missing"]
|
||||
# Nothing was persisted: GET still shows empty copy.
|
||||
assert client.get("/api/v1/slabs/s1").json()["title"] is None
|
||||
|
||||
|
||||
def test_lock_persists_title_and_get_matches(env):
|
||||
"""#89 AC: after lock with species + primary wood category, title/short_desc
|
||||
are populated deterministically, stored, and GET reflects them."""
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["locked"] is True
|
||||
assert body["title"] == "Figured Black Walnut Live Edge Slab"
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["title"] == body["title"]
|
||||
assert got["short_description"] == body["short_description"]
|
||||
assert got["metadata_locked"] is True
|
||||
|
||||
|
||||
def test_lock_requires_species(env):
|
||||
client, conn = env
|
||||
# No species_id -> lock is rejected with a plain message.
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "not_ready"
|
||||
|
||||
|
||||
def test_lock_requires_wood_category(env):
|
||||
client, conn = env
|
||||
# species present but no wood category -> rejected.
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([]),None,None,None,"calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "not_ready"
|
||||
|
||||
|
||||
def test_lock_owner_prose_wins(env):
|
||||
# METADATA-50: typed owner title/short_description win over the pattern.
|
||||
client, conn = env
|
||||
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
|
||||
conn.commit()
|
||||
r = client.post("/api/v1/slabs/s1/review", json={
|
||||
"action": "lock",
|
||||
"title": "My Handcrafted Walnut Slab",
|
||||
"short_description": "Owner-authored short description.",
|
||||
})
|
||||
body = r.json()
|
||||
assert body["title"] == "My Handcrafted Walnut Slab"
|
||||
assert body["short_description"] == "Owner-authored short description."
|
||||
got = client.get("/api/v1/slabs/s1").json()
|
||||
assert got["title"] == "My Handcrafted Walnut Slab"
|
||||
|
||||
Reference in New Issue
Block a user