fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98

- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
This commit is contained in:
Ty
2026-09-21 12:49:53 -07:00
parent 96a697a50a
commit 0c00d25cc7
7 changed files with 328 additions and 108 deletions
+42 -29
View File
@@ -16,7 +16,14 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .db import get_connection
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
from .woo import (
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooError,
WooForbiddenError,
)
from .review import compute_ready, _slab_dict
router = APIRouter(prefix="/api/v1/slabs")
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
if tags:
payload["tags"] = [{"id": t} for t in tags]
# Five locked attributes by synced term name.
# Five locked attributes: resolve the attribute row from the synced cache by
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
# then fall back to case-insensitive name. Term *options* are always the
# names of the selected synced terms.
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
built_attrs = []
for field, attr_name, pos in (
("edge_type_term_id", "Edge Type", 0),
("figure_term_ids", "Figure", 1),
("grade_term_id", "Grade", 2),
("thickness_term_id", "Thickness", 3),
("moisture_term_id", "Moisture", 4),
for field, attr_slug, attr_name, pos in (
("edge_type_term_id", "edge-type", "Edge Type", 0),
("figure_term_ids", "pa_figure", "Figure", 1),
("grade_term_id", "pa_grade", "Grade", 2),
("thickness_term_id", "pa_thickness", "Thickness", 3),
("moisture_term_id", "pa_moisture", "Moisture", 4),
):
raw = slab.get(field)
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
options = [terms[t]["name"] for t in term_ids if t in terms]
attr_id = _attr_id_by_name(attrs, attr_name)
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
if attr_id and options:
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
if built_attrs:
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
return payload
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
"""Match a locked attribute against the synced Woo set by slug, then name."""
slug_l = slug.lower().lstrip()
# Accept both "figure" and "pa_figure" style slugs from Woo.
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
for a in attrs.values():
if a.get("name", "").lower() == name.lower():
s = (a.get("slug") or "").lower()
if s in slug_alts:
return a["id"]
name_l = name.lower()
for a in attrs.values():
if (a.get("name") or "").lower() == name_l:
return a["id"]
return None
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable)."""
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
return "woo_auth", "The store rejected the credentials. Check the app password.", False
if isinstance(e, WooForbiddenError):
# WAF / security plugin block — retrying the same request will not help.
return (
"woo_forbidden",
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
False,
)
if isinstance(e, WooConnectionError):
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
return "woo_failed", "The store create failed. You can retry.", True
@@ -209,26 +235,13 @@ def _create_status(conn) -> str:
def _sku_exists(client: WooClient, sku: str) -> bool:
code, _h, body = client._request(
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET", None,
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
)
if code != 200:
raise WooError(f"sku lookup returned {code}")
return len(json.loads(body)) > 0
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
return len(client.get_products_by_sku(sku)) > 0
def _create_product(client: WooClient, payload: dict) -> int:
body = json.dumps(payload).encode("utf-8")
code, _h, resp = client._request(
f"{client.base_url}/wp-json/wc/v3/products",
"POST", body,
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
)
if code not in (200, 201):
raise WooError(f"product create returned {code}")
return int(json.loads(resp)["id"])
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
return client.create_product(payload)
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None: