"""Publish sequence (PUBLISH-5/10/15/20/25/30/31/36/37/38/45/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). """ from __future__ import annotations import os import re from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter from fastapi.responses import JSONResponse from .db import get_connection from .woo import ( WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError, WooForbiddenError, ) from .review import _slab_dict, compute_ready 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 _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. METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids. Accepts both 'figure' and 'pa_figure' style slugs from Woo. """ slug_l = slug.lower().lstrip() slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"} for a in attrs.values(): if (a.get("slug") or "").lower() 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 _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 each attribute from the synced cache by # slug first, then 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 _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 def _woo_creds(conn) -> tuple[str, str | None, str | None]: base = os.environ.get("WOO_BASE_URL", "") u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() return base, (u[0] if u else None), (p[0] if p else None) 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 _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()), ) @router.post("/{slab_id}/publish") async def publish(slab_id: str) -> 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. base, user, enc = _woo_creds(conn) try: client = WooClient.from_settings(base, user, enc) except WooCredentialsMissing as e: return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e))) try: existing = len(client.get_products_by_sku(slab["sku"])) > 0 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): from . import taxonomy as taxonomy_mod try: 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 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 = client.create_product(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"]), }