49c22c9385
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.
138 lines
6.0 KiB
Python
138 lines
6.0 KiB
Python
"""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]:
|
|
return [t for t in tags if 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,
|
|
}
|