"""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 (commit only on success). Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a stable hash of the stored subset, recorded only when it changes. """ from __future__ import annotations 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]: # The fig-/feat- family marker is on the tag SLUG (e.g. slug 'fig-chat', # name 'Chatoyant'), not the display name. Match either so a store that # prefixes the name instead of the slug is also kept. return [ t for t in tags if (t.get("slug", "").startswith(("fig-", "feat-")) or t.get("name", "").startswith(("fig-", "feat-"))) ] def _normalize_category(c: dict) -> tuple: """Map a Woo REST category row into woo_categories insert values. WooCommerce REST v3 returns `parent` (0 = top-level); older fixtures may already use `parent_id`. Never invent ids — only store what the payload carries. """ raw_parent = c.get("parent_id", c.get("parent")) parent_id = None if raw_parent in (0, "0", None, "") else int(raw_parent) woo_id = c.get("woo_id", c.get("id")) if woo_id is not None: woo_id = int(woo_id) return (c["id"], c["name"], c["slug"], parent_id, woo_id) 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; the prior cache survives because we 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) # One explicit transaction: the rebuild is atomic, and on any failure the # prior cache survives (autocommit mode — see db factory). conn.execute("BEGIN") # Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild. conn.execute("DELETE FROM woo_attribute_terms") conn.execute("DELETE FROM woo_attributes") conn.execute("DELETE FROM woo_tags") # Categories: upsert, then drop stale rows. A blanket DELETE fails when # slabs or pricing_rules still reference species_id (live UAT IntegrityError). cat_rows = [_normalize_category(c) for c in categories] cat_ids = [r[0] for r in cat_rows] for row in cat_rows: conn.execute( "INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?) " "ON CONFLICT(id) DO UPDATE SET " "name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id", row, ) keep = ( f"AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) " f"AND id NOT IN (SELECT species_id FROM pricing_rules)" ) if cat_ids: placeholders = ",".join("?" * len(cat_ids)) conn.execute(f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) {keep}", cat_ids) else: conn.execute(f"DELETE FROM woo_categories {keep}") 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.execute("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, }