2f6335ec26
Prototype routing so 2–3 owners can share one store URL with different WP application passwords. Device creds live in localStorage; server uses request override → Settings fallback and never writes overrides into the Settings row. Inference stays on shared Settings. See PROTOTYPE.md for UAT with two browsers and known LAN forge risk.
250 lines
11 KiB
Python
250 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,
|
|
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, 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: X-Woo-* headers → Settings fallback (never persisted).
|
|
try:
|
|
client = client_for_request(conn, request.headers)
|
|
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()),
|
|
)
|