feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
+71
-71
@@ -1,20 +1,20 @@
|
||||
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
|
||||
"""WooCommerce REST v3 client (httpx, WordPress app-password basic auth, HTTPS-only).
|
||||
|
||||
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).
|
||||
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). The transport is
|
||||
a single injectable call so tests never hit a real store (mock boundary).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
from .crypto import DecryptError, decrypt
|
||||
import httpx
|
||||
|
||||
# WordPress security plugins (Wordfence, WAF, CDN) commonly block the default
|
||||
# Python-urllib User-Agent with 403. Send a descriptive one.
|
||||
# Python User-Agent with 403. Send a descriptive one (#79).
|
||||
_USER_AGENT = "SlabUploader/1.0 (+https://www.whidbeywoodstore.com)"
|
||||
|
||||
|
||||
@@ -31,74 +31,93 @@ class WooAuthError(WooError):
|
||||
|
||||
|
||||
class WooForbiddenError(WooError):
|
||||
"""The store blocked the request (403) — usually a security plugin or WAF."""
|
||||
"""The store blocked the request (403) — security plugin / WAF."""
|
||||
|
||||
|
||||
class WooNotFoundError(WooError):
|
||||
pass
|
||||
"""The store returned 404."""
|
||||
|
||||
|
||||
class WooServerError(WooError):
|
||||
pass
|
||||
"""The store returned a 5xx or an unexpected status."""
|
||||
|
||||
|
||||
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)
|
||||
Transport = Callable[[str, str, bytes | None, dict[str, str]], tuple[int, dict, bytes]]
|
||||
|
||||
|
||||
def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]) -> tuple[int, dict, bytes]:
|
||||
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:
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
resp = client.request(method, url, content=data, headers=headers)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as e:
|
||||
raise WooConnectionError(f"store unreachable: {e}") from e
|
||||
return resp.status_code, dict(resp.headers.items()), resp.content
|
||||
|
||||
|
||||
def _message(body: bytes) -> str | None:
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
msg = parsed.get("message")
|
||||
if isinstance(msg, str) and msg:
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
class WooClient:
|
||||
"""Minimal Woo REST v3 client. `request` is the transport seam for tests."""
|
||||
|
||||
def __init__(self, username: str, password: str, base_url: str,
|
||||
request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None:
|
||||
request: Transport | None = None) -> 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._request: Transport = request or _default_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)."""
|
||||
request: Transport | None = None) -> "WooClient":
|
||||
"""Build from Settings values; raise WooCredentialsMissing if incomplete."""
|
||||
from .crypto import DecryptError, decrypt
|
||||
|
||||
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
|
||||
except DecryptError:
|
||||
raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from None
|
||||
return cls(username, password, base_url, request)
|
||||
|
||||
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
h = {
|
||||
"Authorization": f"Basic {self._auth}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
}
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
def _raise_for(self, code: int, body: bytes) -> None:
|
||||
if code == 401:
|
||||
raise WooAuthError(_message(body) or "authentication rejected (401)")
|
||||
if code == 403:
|
||||
raise WooForbiddenError(_message(body) or "request forbidden (403)")
|
||||
if code == 404:
|
||||
raise WooNotFoundError(_message(body) or "not found (404)")
|
||||
raise WooServerError(f"server error {code}: {_message(body) or ''}".strip(": "))
|
||||
|
||||
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())
|
||||
return self._request(f"{self.base_url}/wp-json/wc/v3{path}", "GET", None, self._auth_headers())
|
||||
|
||||
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
@@ -106,7 +125,7 @@ class WooClient:
|
||||
while True:
|
||||
code, headers, body = self._get(f"{path}?per_page=100&page={page}")
|
||||
if code != 200:
|
||||
raise WooServerError(f"expected 200, got {code}")
|
||||
self._raise_for(code, body)
|
||||
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:
|
||||
@@ -125,55 +144,36 @@ class WooClient:
|
||||
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(),
|
||||
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}")
|
||||
self._raise_for(code, body)
|
||||
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,
|
||||
f"{self.base_url}/wp-json/wc/v3/products", "POST",
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
self._auth_headers({"Content-Type": "application/json"}),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"product create returned {code}")
|
||||
self._raise_for(code, resp)
|
||||
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."""
|
||||
"""POST a PNG to WordPress core media; returns 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,
|
||||
code, _h, resp = self._request(
|
||||
f"{self.base_url}/wp-json/wp/v2/media", "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}")
|
||||
self._raise_for(code, resp)
|
||||
return int(json.loads(resp)["id"])
|
||||
|
||||
Reference in New Issue
Block a user