Files
SlabUploader/backend/woo.py
T
Ty 0c00d25cc7 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
2026-09-21 12:55:17 -07:00

180 lines
7.0 KiB
Python

"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds
raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false),
distinct from WooAuthError (a 401 from the store).
"""
import base64
import json
import urllib.error
import urllib.request
from typing import Any, Callable
from .crypto import DecryptError, decrypt
# WordPress security plugins (Wordfence, WAF, CDN) commonly block the default
# Python-urllib User-Agent with 403. Send a descriptive one.
_USER_AGENT = "SlabUploader/1.0 (+https://www.whidbeywoodstore.com)"
class WooError(Exception):
"""Base for all Woo client failures."""
class WooCredentialsMissing(WooError):
"""Woo base URL or credentials are not configured (fail-closed)."""
class WooAuthError(WooError):
"""The store rejected the credentials (401)."""
class WooForbiddenError(WooError):
"""The store blocked the request (403) — usually a security plugin or WAF."""
class WooNotFoundError(WooError):
pass
class WooServerError(WooError):
pass
class WooConnectionError(WooError):
"""Store unreachable (DNS, TLS, timeout)."""
def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]):
req = urllib.request.Request(url, method=method, data=data)
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.getcode(), dict(r.headers.items()), r.read()
except urllib.error.HTTPError as e:
body = e.read()
try:
parsed = json.loads(body)
except (ValueError, json.JSONDecodeError):
parsed = {}
msg = parsed.get("message") if isinstance(parsed, dict) else None
if e.code == 401:
raise WooAuthError(msg or "authentication rejected (401)") from e
if e.code == 403:
raise WooForbiddenError(msg or "request forbidden (403)") from e
if e.code == 404:
raise WooNotFoundError(msg or "not found (404)") from e
raise WooServerError(f"server error {e.code}: {msg}") from e
except (urllib.error.URLError, OSError, TimeoutError) as e:
raise WooConnectionError(f"store unreachable: {e}") from e
class WooClient:
def __init__(self, username: str, password: str, base_url: str,
request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None:
if not base_url or not base_url.startswith("https://"):
raise WooCredentialsMissing("WOO_BASE_URL must be https:// (SETTINGS-10)")
self.base_url = base_url.rstrip("/")
self._request = request
self._auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
@classmethod
def from_settings(cls, base_url: str, username: str | None, encrypted_password: str | None,
request: Callable[..., tuple[int, dict, bytes]] | None = None) -> "WooClient":
"""Build from Settings values; raise WooCredentialsMissing if incomplete (fail-closed)."""
if not username:
raise WooCredentialsMissing("woo_wp_username not set (SETTINGS-15)")
if not encrypted_password:
raise WooCredentialsMissing("woo_app_password not configured (SETTINGS-15)")
try:
password = decrypt(encrypted_password)
except DecryptError as e:
raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from e
if request is None:
request = _default_request
return cls(username, password, base_url, request)
def _get(self, path: str) -> tuple[int, dict, bytes]:
url = f"{self.base_url}/wp-json/wc/v3{path}"
return self._request(url, "GET", None, self._auth_headers())
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
page = 1
results: list[dict[str, Any]] = []
while True:
code, headers, body = self._get(f"{path}?per_page=100&page={page}")
if code != 200:
raise WooServerError(f"expected 200, got {code}")
results.extend(json.loads(body))
total_pages = int(headers.get("X-WP-TotalPages") or headers.get("x-wp-totalpages") or 1)
if page >= total_pages:
return results
page += 1
def get_categories(self) -> list[dict[str, Any]]:
return self._fetch_all("/products/categories")
def get_tags(self) -> list[dict[str, Any]]:
return self._fetch_all("/products/tags")
def get_attributes(self) -> list[dict[str, Any]]:
return self._fetch_all("/products/attributes")
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
h = {
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
}
if extra:
h.update(extra)
return h
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
code, _h, body = self._request(
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET",
None,
self._auth_headers(),
)
if code != 200:
raise WooServerError(f"sku lookup returned {code}")
return list(json.loads(body))
def create_product(self, payload: dict[str, Any]) -> int:
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
body = json.dumps(payload).encode("utf-8")
code, _h, resp = self._request(
f"{self.base_url}/wp-json/wc/v3/products",
"POST",
body,
self._auth_headers({"Content-Type": "application/json"}),
)
if code not in (200, 201):
raise WooServerError(f"product create returned {code}")
return int(json.loads(resp)["id"])
def upload_media(self, png_bytes: bytes, filename: str) -> int:
"""POST a PNG to WordPress core media; return the attachment id."""
boundary = "----slabuploader-media-boundary"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode("utf-8") + png_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8")
url = f"{self.base_url}/wp-json/wp/v2/media"
code, _headers, resp = self._request(
url, "POST", body,
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
)
if code not in (200, 201):
raise WooServerError(f"media upload returned {code}")
return int(json.loads(resp)["id"])