Files
SlabUploader/backend/publish.py
T
grokbot_ty 2f9b8ebd02 proto(woo): blocking setup gate; require X-Woo-* on Woo HTTP routes
Full-screen WooSetupGate on App mount when this browser lacks device WP
user + app password. Save + Test Woo dismisses only after a successful
test. Publish/test-woo auth failures reopen the gate.

Server: client_for_request requires X-Woo-* on publish, taxonomy sync,
and test-woo (401 woo_device_creds_required); health probe keeps
Settings. Optional SLAB_WOO_ALLOW_SETTINGS_FALLBACK for legacy tests.

See PROTOTYPE.md.
2026-09-26 19:20:16 -07:00

265 lines
11 KiB
Python

"""Publish sequence (S6). Cites PUBLISH-5/10/15/20/25/30/31/36/37/38/55/60.
Sequence (PUBLISH-5): validate ready set -> look up SKU (stop if exists) ->
refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload processed
PNGs -> POST /products -> finalize. SLAB-UAT-* always draft (PUBLISH-10).
Failure: mark failed, retain photos, write sync_log, plain message (PUBLISH-30).
Partial (media ok, product POST failed): orphan media left, logged (PUBLISH-31).
"""
import json
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from .db import get_connection
from .woo import (
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooDeviceCredsRequired,
WooError,
WooForbiddenError,
client_for_request,
settings_woo_creds,
)
from .review import compute_ready, _slab_dict
router = APIRouter(prefix="/api/v1/slabs")
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 _is_uat(sku: str) -> bool:
return sku.startswith("SLAB-UAT-")
def _slugify(name: str, sku: str) -> str:
base = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return f"{base}-{sku.lower()}"
def _taxonomy_stale(conn) -> bool:
row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
if not row or not row[0]:
return True
try:
last = datetime.fromisoformat(row[0])
except ValueError:
return True
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) - last > timedelta(hours=1)
def _build_payload(slab: dict, conn, status: str) -> dict:
payload: dict[str, Any] = {
"type": "simple",
"status": status,
"sku": slab["sku"],
"name": slab["title"],
"slug": _slugify(slab["title"], slab["sku"]),
"regular_price": f"{slab['price']:.2f}",
"description": slab["description"],
"short_description": slab["short_description"],
"virtual": False,
"downloadable": False,
"catalog_visibility": "visible",
"sold_individually": True,
}
wood_cats = slab.get("wood_category_ids") or []
payload["categories"] = [{"id": slab["species_id"]}]+[{"id": c} for c in wood_cats]
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: 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_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_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:
payload["attributes"] = built_attrs
return payload
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():
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). PUBLISH-36/37/38."""
if isinstance(e, WooDeviceCredsRequired):
return (
"woo_device_creds_required",
"Enter this device's WordPress user and application password.",
False,
)
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
@router.post("/{slab_id}/publish")
async def publish(slab_id: str, request: Request) -> dict:
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row)
# 1. Ready set (PUBLISH-5)
ready, missing = compute_ready(slab)
if not ready:
return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing}))
# 2. SKU lookup (PUBLISH-15) — stop if it exists in the store.
# Per-request Woo creds: require X-Woo-* device headers (no Settings fallback).
try:
client = client_for_request(conn, request.headers)
except WooDeviceCredsRequired as e:
return JSONResponse(
status_code=401,
content=_error(
"woo_device_creds_required",
"Enter this device's WordPress user and application password.",
),
)
except WooCredentialsMissing as e:
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
try:
existing = _sku_exists(client, slab["sku"])
except WooError as e:
code, msg, rec = _classify(e)
_log(conn, slab_id, None, "failed", msg)
conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
conn.commit()
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
if existing:
_log(conn, slab_id, None, "failed", "SKU already exists in the store")
return JSONResponse(status_code=409, content=_error("duplicate_sku", "SKU already exists", {"sku": slab["sku"]}))
# 3. Refresh taxonomy if stale (SETTINGS-60).
if _taxonomy_stale(conn):
try:
from . import taxonomy as taxonomy_mod
taxonomy_mod.sync(client, conn)
except WooError as e:
code, msg, rec = _classify(e)
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
# 4/5. Upload processed PNGs, then POST product.
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
# its dimension-overlay copy, then the remaining batch photos. The Review
# photo strip stores the owner's keep order (leftmost = primary), so the
# overlay (which can sit after a moved batch photo) is re-ordered here by
# role rather than by stored seq. Ties fall back to stored seq.
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
photos = conn.execute(
"SELECT id, processed_blob, role FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL "
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
(slab_id,),
).fetchall()
uploaded_ids: list[int] = []
try:
for p in photos:
uploaded_ids.append(client.upload_media(p["processed_blob"], f"{slab['sku']}-{p['id']}.png"))
payload = _build_payload(slab, conn, status)
if uploaded_ids:
payload["images"] = [{"id": i} for i in uploaded_ids]
woo_product_id = _create_product(client, payload)
except WooError as e:
code, msg, rec = _classify(e)
partial = bool(uploaded_ids)
if partial:
msg = f"{msg} The uploaded images were left in the store — remove them manually."
_log(conn, slab_id, None, "failed", msg)
conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
conn.commit()
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec, "partial_media": partial}))
# 6. Finalize (PUBLISH-25): set woo_product_id, purge photos, discard local row.
now = _now()
conn.execute("UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?",
(woo_product_id, now, now, slab_id))
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE slab_id=?", (slab_id,))
_log(conn, slab_id, woo_product_id, "success", "published")
conn.commit()
return {
"ok": True,
"status": "published",
"woo_product_id": woo_product_id,
"draft": _is_uat(slab["sku"]),
}
def _woo_creds(conn) -> tuple[str, str | None, str | None]:
"""Shop-default Woo creds (Settings). Prefer client_for_request for HTTP paths."""
return settings_woo_creds(conn)
def _create_status(conn) -> str:
row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone()
return (row[0] if row and row[0] else "draft")
def _sku_exists(client: WooClient, sku: str) -> bool:
"""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:
"""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:
conn.execute(
"INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)",
(slab_id, woo_product_id, status, None, message, _now()),
)