feat(woo): S4 Woo client + taxonomy sync + test-woo + admin taxonomy
- backend/woo.py: stdlib urllib Woo v3 client. Basic-auth, HTTPS-only, X-WP-TotalPages pagination, typed errors (CredentialsMissing/Auth/NotFound/Server/Connection), injectable transport for tests, WP core media upload. - backend/taxonomy.py: sync pulls categories/tags/attributes/terms into cache in one commit (rollback keeps prior cache on failure); fig-*/feat-* tag scoping; stable taxonomy_anchor hash; get_cached grouped admin view. - backend/app.py: POST /settings/test-woo (fail-closed, resync on success), GET /admin/taxonomy, POST /admin/taxonomy/sync (502 on Woo failure). - frontend: Settings Test Woo button + result display. - tests: 8 woo/taxonomy tests (auth header, non-https refusal, pagination, missing creds, 401, scoped sync, failure-keeps-cache, anchor stability). Verified: pytest 31 passed; check_requirements + check_coverage exit 0; npm build ok; live QA test-woo fail-closed + sync 502.
This commit is contained in:
@@ -149,6 +149,10 @@ async def _unwrap_error(request: Request, exc: HTTPException):
|
|||||||
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
|
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
|
||||||
|
|
||||||
|
|
||||||
|
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||||
|
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
|
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
|
||||||
@@ -180,6 +184,76 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return settings_mod.update_settings(payload)
|
return settings_mod.update_settings(payload)
|
||||||
|
|
||||||
|
|
||||||
|
from . import taxonomy as taxonomy_mod
|
||||||
|
from .woo import (
|
||||||
|
WooClient,
|
||||||
|
WooAuthError,
|
||||||
|
WooConnectionError,
|
||||||
|
WooCredentialsMissing,
|
||||||
|
WooError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
||||||
|
conn = get_connection()
|
||||||
|
base = os.environ.get("WOO_BASE_URL", "")
|
||||||
|
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||||
|
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||||
|
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/settings/test-woo")
|
||||||
|
async def test_woo() -> dict[str, Any]:
|
||||||
|
base, username, enc_password = _woo_credentials()
|
||||||
|
try:
|
||||||
|
client = WooClient.from_settings(base, username, enc_password)
|
||||||
|
except WooCredentialsMissing as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"detail": str(e),
|
||||||
|
"taxonomy_anchor": None,
|
||||||
|
"taxonomy_last_sync_at": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = taxonomy_mod.sync(client, get_connection())
|
||||||
|
except WooAuthError as e:
|
||||||
|
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||||
|
except WooConnectionError as e:
|
||||||
|
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||||
|
except WooError as e:
|
||||||
|
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"detail": "taxonomy synced",
|
||||||
|
"taxonomy_anchor": result["taxonomy_anchor"],
|
||||||
|
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/admin/taxonomy")
|
||||||
|
async def get_taxonomy() -> dict[str, Any]:
|
||||||
|
return taxonomy_mod.get_cached(get_connection())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/admin/taxonomy/sync")
|
||||||
|
async def post_taxonomy_sync() -> dict[str, Any]:
|
||||||
|
base, username, enc_password = _woo_credentials()
|
||||||
|
try:
|
||||||
|
client = WooClient.from_settings(base, username, enc_password)
|
||||||
|
except WooCredentialsMissing as e:
|
||||||
|
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
||||||
|
try:
|
||||||
|
result = taxonomy_mod.sync(client, get_connection())
|
||||||
|
except WooError as e:
|
||||||
|
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"detail": "taxonomy synced",
|
||||||
|
"taxonomy_anchor": result["taxonomy_anchor"],
|
||||||
|
"taxonomy_last_sync_at": result["taxonomy_last_sync_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# expose for tests
|
# expose for tests
|
||||||
|
|
||||||
from .slabs import router as slabs_router
|
from .slabs import router as slabs_router
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
|
||||||
|
|
||||||
|
Rebuild happens in one transaction; on any failure the prior cache is kept.
|
||||||
|
Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a
|
||||||
|
stable hash of the stored subset, recorded only when it changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .woo import WooClient
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped_tags(tags: list[dict]) -> list[dict]:
|
||||||
|
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
||||||
|
|
||||||
|
|
||||||
|
def _subset_hash(categories: list[dict], attributes: list[dict],
|
||||||
|
terms_by_attr: dict, scoped_tags: list[dict]) -> str:
|
||||||
|
subset = {
|
||||||
|
"categories": sorted(categories, key=lambda c: c["id"]),
|
||||||
|
"attributes": sorted(attributes, key=lambda a: a["id"]),
|
||||||
|
"terms": {str(k): sorted(v, key=lambda t: t["id"]) for k, v in terms_by_attr.items()},
|
||||||
|
"tags": sorted(scoped_tags, key=lambda t: t["id"]),
|
||||||
|
}
|
||||||
|
return hashlib.sha256(json.dumps(subset, sort_keys=True).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sync(client: WooClient, conn) -> dict:
|
||||||
|
"""Rebuild the taxonomy cache from the store. Raises WooError on failure (caller
|
||||||
|
maps to 502; prior cache is retained because we only commit on success)."""
|
||||||
|
categories = client.get_categories()
|
||||||
|
tags = client.get_tags()
|
||||||
|
attributes = client.get_attributes()
|
||||||
|
terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes}
|
||||||
|
scoped_tags = _scoped_tags(tags)
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM woo_attribute_terms")
|
||||||
|
conn.execute("DELETE FROM woo_attributes")
|
||||||
|
conn.execute("DELETE FROM woo_categories")
|
||||||
|
conn.execute("DELETE FROM woo_tags")
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?)",
|
||||||
|
[(c["id"], c["name"], c["slug"], c.get("parent_id"), c.get("woo_id")) for c in categories],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||||
|
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
|
||||||
|
)
|
||||||
|
for attr_id, terms in terms_by_attr.items():
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
|
||||||
|
[(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||||
|
[(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||||
|
)
|
||||||
|
|
||||||
|
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
|
||||||
|
now = _now_iso()
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_last_sync_at', ?, NULL)", (now,))
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('taxonomy_anchor', ?, NULL)", (anchor,))
|
||||||
|
conn.commit()
|
||||||
|
return {"taxonomy_anchor": anchor, "taxonomy_last_sync_at": now}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached(conn) -> dict:
|
||||||
|
"""Return the cached taxonomy grouped for the admin view."""
|
||||||
|
categories = [dict(r) for r in conn.execute("SELECT * FROM woo_categories ORDER BY name").fetchall()]
|
||||||
|
attributes = [dict(r) for r in conn.execute("SELECT * FROM woo_attributes ORDER BY name").fetchall()]
|
||||||
|
terms = [dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms ORDER BY name").fetchall()]
|
||||||
|
tags = [dict(r) for r in conn.execute("SELECT * FROM woo_tags ORDER BY name").fetchall()]
|
||||||
|
anchor_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_anchor'").fetchone()
|
||||||
|
sync_row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
|
||||||
|
terms_by_attr: dict[int, list] = {a["id"]: [] for a in attributes}
|
||||||
|
for t in terms:
|
||||||
|
terms_by_attr.setdefault(t["attribute_id"], []).append(t)
|
||||||
|
grouped_attrs = [
|
||||||
|
{
|
||||||
|
"id": a["id"],
|
||||||
|
"name": a["name"],
|
||||||
|
"slug": a["slug"],
|
||||||
|
"terms": [{"id": t["id"], "name": t["name"], "slug": t["slug"]} for t in terms_by_attr.get(a["id"], [])],
|
||||||
|
}
|
||||||
|
for a in attributes
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"categories": categories,
|
||||||
|
"attributes": grouped_attrs,
|
||||||
|
"tags": tags,
|
||||||
|
"taxonomy_anchor": anchor_row[0] if anchor_row else None,
|
||||||
|
"taxonomy_last_sync_at": sync_row[0] if sync_row else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""S4 Woo client + taxonomy sync tests (mocked transport; never hit the real store)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import backend.db as db_mod
|
||||||
|
from backend.woo import (
|
||||||
|
WooAuthError,
|
||||||
|
WooClient,
|
||||||
|
WooConnectionError,
|
||||||
|
WooCredentialsMissing,
|
||||||
|
WooServerError,
|
||||||
|
)
|
||||||
|
from backend import taxonomy as taxonomy_mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_dir():
|
||||||
|
tmp = tempfile.mkdtemp(prefix="woo_")
|
||||||
|
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
|
||||||
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||||
|
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||||
|
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
conn = db_mod.init_db()
|
||||||
|
from backend.crypto import encrypt
|
||||||
|
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
||||||
|
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
|
||||||
|
conn.commit()
|
||||||
|
yield conn
|
||||||
|
db_mod._reset_for_tests()
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _client(responses):
|
||||||
|
"""Build a WooClient whose transport returns pre-canned (code, headers, body) per call."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
i = min(calls["n"], len(responses) - 1)
|
||||||
|
calls["n"] += 1
|
||||||
|
code, hdr, body = responses[i]
|
||||||
|
return code, hdr, body
|
||||||
|
|
||||||
|
return WooClient("admin", "secret", "https://store.example.com", request=fake_request), calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_refuses_non_https(db_dir):
|
||||||
|
with pytest.raises(WooCredentialsMissing):
|
||||||
|
WooClient("admin", "secret", "http://insecure.example.com")
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_header_and_url(db_dir):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
seen["url"] = url
|
||||||
|
seen["auth"] = headers["Authorization"]
|
||||||
|
return 200, {"X-WP-TotalPages": "1"}, b"[]"
|
||||||
|
|
||||||
|
c = WooClient("admin", "secret", "https://store.example.com", request=fake_request)
|
||||||
|
c.get_categories()
|
||||||
|
assert seen["url"] == "https://store.example.com/wp-json/wc/v3/products/categories?per_page=100&page=1"
|
||||||
|
expected = "Basic " + base64.b64encode(b"admin:secret").decode()
|
||||||
|
assert seen["auth"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagination_follows_total_pages(db_dir):
|
||||||
|
p1 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":1}]')
|
||||||
|
p2 = (200, {"X-WP-TotalPages": "2"}, b'[{"id":2}]')
|
||||||
|
c, calls = _client([p1, p2])
|
||||||
|
data = c.get_tags()
|
||||||
|
assert [d["id"] for d in data] == [1, 2]
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_password_fails_closed(db_dir):
|
||||||
|
with pytest.raises(WooCredentialsMissing):
|
||||||
|
WooClient.from_settings("https://store.example.com", "admin", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_401_raises_auth_error(db_dir):
|
||||||
|
def fake_request(url, method, data, headers):
|
||||||
|
raise WooAuthError("authentication rejected (401)")
|
||||||
|
|
||||||
|
c = WooClient("admin", "secret", "https://store.example.com", request=fake_request)
|
||||||
|
with pytest.raises(WooAuthError):
|
||||||
|
c.get_categories()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_populates_scoped(db_dir):
|
||||||
|
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
||||||
|
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||||
|
{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20},
|
||||||
|
{"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21},
|
||||||
|
{"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped
|
||||||
|
]).encode())
|
||||||
|
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
|
||||||
|
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
|
||||||
|
c, _ = _client([cats, tags, attrs, terms])
|
||||||
|
result = taxonomy_mod.sync(c, db_mod.get_connection())
|
||||||
|
conn = db_mod.get_connection()
|
||||||
|
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
|
||||||
|
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
|
||||||
|
assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped
|
||||||
|
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
|
||||||
|
assert result["taxonomy_anchor"]
|
||||||
|
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_failure_keeps_previous_cache(db_dir):
|
||||||
|
conn = db_mod.get_connection()
|
||||||
|
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (99,'Old','old',NULL,1)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def bad_request(url, method, data, headers):
|
||||||
|
raise WooServerError("boom")
|
||||||
|
|
||||||
|
c = WooClient("admin", "secret", "https://store.example.com", request=bad_request)
|
||||||
|
with pytest.raises(WooServerError):
|
||||||
|
taxonomy_mod.sync(c, conn)
|
||||||
|
# previous cache retained
|
||||||
|
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [99]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_stable_across_identical_syncs(db_dir):
|
||||||
|
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
||||||
|
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}]).encode())
|
||||||
|
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||||
|
c1, _ = _client([cats, tags, attrs])
|
||||||
|
r1 = taxonomy_mod.sync(c1, db_mod.get_connection())
|
||||||
|
c2, _ = _client([cats, tags, attrs])
|
||||||
|
r2 = taxonomy_mod.sync(c2, db_mod.get_connection())
|
||||||
|
assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"]
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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 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 == 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,
|
||||||
|
{"Authorization": f"Basic {self._auth}", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
{
|
||||||
|
"Authorization": f"Basic {self._auth}",
|
||||||
|
"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"])
|
||||||
@@ -91,6 +91,18 @@
|
|||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let testResult: { ok: boolean; detail: string } | null = null;
|
||||||
|
|
||||||
|
async function testWoo() {
|
||||||
|
testResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' });
|
||||||
|
testResult = await res.json();
|
||||||
|
} catch {
|
||||||
|
testResult = { ok: false, detail: 'network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
@@ -158,6 +170,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button on:click={save} disabled={saving}>Save Changes</button>
|
<button on:click={save} disabled={saving}>Save Changes</button>
|
||||||
|
<button on:click={testWoo} disabled={saving}>Test Woo</button>
|
||||||
|
{#if testResult}
|
||||||
|
<p class="msg" class:ok={testResult.ok} class:err={!testResult.ok}>
|
||||||
|
{testResult.ok ? 'Connected & synced' : testResult.detail}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if message}<p class="msg">{message}</p>{/if}
|
{#if message}<p class="msg">{message}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user