5e03f45660
- backend/publish.py: POST /slabs/{id}/publish. Sequence (PUBLISH-5): validate ready
set -> SKU lookup (409 on dup, PUBLISH-15) -> refresh taxonomy if >1h (SETTINGS-60)
-> resolve cats/tags/5 attributes -> upload processed PNGs -> POST /products ->
finalize (set woo_product_id, purge photos, PUBLISH-25). SLAB-UAT-* always draft
(PUBLISH-10). Failure: mark failed, retain photos, sync_log, plain recoverable/
unrecoverable message (PUBLISH-30/36/37/38). Partial (media ok, POST failed):
orphan media logged + manual cleanup note (PUBLISH-31). Payload: only non-null,
slug = name+sku, sold_individually true (PUBLISH-55/60).
- frontend: create/publish/Publish.svelte (slab id, publish, result + recoverable/
orphan tags) + dev toggle.
- tests: 5 publish tests (success+purge, UAT draft, not-ready 422, dup 409,
partial-media 502). Woo transport mocked.
Verified: pytest 48 passed; gates exit 0; npm build ok.
232 lines
9.5 KiB
Python
232 lines
9.5 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
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .db import get_connection
|
|
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
|
|
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 by synced term name.
|
|
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),
|
|
):
|
|
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)
|
|
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_by_name(attrs: dict, name: str) -> int | None:
|
|
for a in attrs.values():
|
|
if a.get("name", "").lower() == name.lower():
|
|
return a["id"]
|
|
return None
|
|
|
|
|
|
def _classify(e: Exception) -> tuple[str, str, bool]:
|
|
"""Return (code, plain message, recoverable)."""
|
|
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, 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) -> 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 = _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.
|
|
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
|
|
photos = conn.execute(
|
|
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY 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]:
|
|
import os
|
|
|
|
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 _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
|
|
|
|
|
|
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"])
|
|
|
|
|
|
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()),
|
|
)
|