From db1788bfe5bd1d6f4f38234a38bac908991de0e3 Mon Sep 17 00:00:00 2001 From: type-two Date: Thu, 2 Jul 2026 23:07:28 +1000 Subject: [PATCH] feat(app): payments/packs/discogs-mp/dealgod modules + admin, shop, sales route work Batch-committing the in-flight app work (all modules compile clean): - app/payments.py, app/packs.py, app/discogs_mp.py, app/dealgod.py (new) - admin_routes, main, navigator/sales/shop routes, site/search.html - customer_woo_sync + disc_sync tweaks; test_dealgod_supply, test_startup_ddl Co-Authored-By: Claude Fable 5 --- app/admin_routes.py | 360 ++++++++++++++++++++++++++++++++++++++-- app/dealgod.py | 160 ++++++++++++++++++ app/discogs_mp.py | 214 ++++++++++++++++++++++++ app/main.py | 126 +++++++++++++- app/navigator_routes.py | 7 +- app/packs.py | 127 ++++++++++++++ app/payments.py | 141 ++++++++++++++++ app/sales_routes.py | 46 +++-- app/shop_routes.py | 132 +++++++++++++++ customer_woo_sync.py | 2 +- disc_sync.py | 3 +- site/search.html | 65 ++++++-- test_dealgod_supply.py | 68 ++++++++ test_startup_ddl.py | 54 ++++++ 14 files changed, 1447 insertions(+), 58 deletions(-) create mode 100644 app/dealgod.py create mode 100644 app/discogs_mp.py create mode 100644 app/packs.py create mode 100644 app/payments.py create mode 100644 test_dealgod_supply.py create mode 100644 test_startup_ddl.py diff --git a/app/admin_routes.py b/app/admin_routes.py index dc2af2b..a34031d 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -40,7 +40,9 @@ async def me(ident=Depends(require_token), db=Depends(get_db)): if ident.get("staff_id"): await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": ident["staff_id"]}) await db.commit() - return {"name": ident.get("name"), "role": ident.get("role"), "staff_id": ident.get("staff_id")} + from . import packs + return {"name": ident.get("name"), "role": ident.get("role"), "staff_id": ident.get("staff_id"), + **packs.info()} # ── Staff accounts (ADMIN ONLY) — operators get a token + role; tokens shown once on create/reset ── @@ -260,21 +262,10 @@ _COLOUR_RE = re.compile( async def _dealgod_supply(db, release_ids): - """Batch DealGod /api/supply → {release_id(str): {store_count, discogs_seller_count, …}}.""" - key = await vault.get_secret(db, "dealgod_api_key") - if not key or not release_ids: - return {} - out = {} - async with httpx.AsyncClient(timeout=25, headers={"X-API-Key": key}) as c: - for i in range(0, len(release_ids), 200): - try: - r = await c.post("https://api.dealgod.pro/api/supply", - json={"ids": release_ids[i:i + 200]}) - if r.status_code == 200: - out.update(r.json().get("results", {})) - except Exception: - pass - return out + """Batch DealGod /api/supply → {release_id(str): {store_count, discogs_seller_count, …}}. + Now delegates to the shared DealGod client (app/dealgod.py) — the single seam to the brain.""" + from . import dealgod + return await dealgod.supply(db, release_ids) class WishItem(BaseModel): @@ -402,6 +393,139 @@ class InvItemIn(BaseModel): in_stock: bool = True +@router.get("/value/{sku}") +async def dealgod_value(sku: str, ident=Depends(require_token), db=Depends(get_db)): + """DealGod value overlay for one item — records resolve via release_id, everything else via canon_id + (the StoreGod generalisation). Canon goods also get lore (tier/faults). Gated on the pack's 'value' feature.""" + from . import dealgod, packs + if not packs.has_feature("value"): + return {"ok": False, "reason": "value not enabled for this pack"} + row = (await db.execute(text( + "SELECT release_id, canon_id, kind, condition_type FROM inventory WHERE sku = :s AND store_id = :st"), + {"s": sku, "st": ident["store_id"]})).mappings().first() + if not row: + raise HTTPException(404, "not found") + cond = "new" if row["condition_type"] == "new" else "used-good" + if row["release_id"]: + val = await dealgod.value(db, release_id=row["release_id"], condition=cond) + return {"ok": True, "value": val, "ref": {"type": "release", "id": row["release_id"]}} + if row["canon_id"]: + val = await dealgod.value(db, canon_id=row["canon_id"], condition=cond) + lore = await dealgod.lore(db, row["canon_id"]) if packs.has_feature("lore") else None + return {"ok": True, "value": val, "lore": lore, "ref": {"type": "canon", "id": row["canon_id"]}} + return {"ok": True, "value": None, + "note": "no DealGod ref — identify it first (records→release_id, others→canon_id)"} + + +class IdentifyIn(BaseModel): + clues: dict = {} + scope: list[str] | None = None + + +@router.post("/identify") +async def dealgod_identify(body: IdentifyIn, ident=Depends(require_token), db=Depends(get_db)): + """Universal 'what is this' for the back-office → DealGod /api/identify. Defaults scope to the active + pack's domains (records bypass this — they use the Discogs matcher). Returns normalised candidates.""" + from . import dealgod, packs + if not packs.has_feature("identify"): + return {"ok": False, "reason": "identify not enabled for this pack"} + scope = body.scope or packs.active_scope() + if scope == ["*"]: + scope = None # vanilla StoreGod base → no domain filter + r = await dealgod.identify(db, body.clues, scope=scope) + return {"ok": True, **r} + + +class StageIdentifyIn(BaseModel): + ref_type: str = "canon" # canon | release + ref_id: int + title: str | None = None + kind: str = "other" + identifier: str | None = None # the scanned barcode/ISBN + price: float | None = None + condition: str | None = None + + +@router.post("/intake/identify") +async def intake_identify(body: StageIdentifyIn, ident=Depends(require_token), db=Depends(get_db)): + """Stage an item from an /api/identify candidate — the universal intake for non-record packs + (records keep their Discogs intake). release → release_id, canon → canon_id.""" + from .intake_routes import _stage + rid = body.ref_id if body.ref_type == "release" else None + canon = body.ref_id if body.ref_type == "canon" else None + res = await _stage(db, ident["store_id"], rid, None, body.condition, None, body.price, None, + kind=body.kind, identifier=body.identifier, canon_id=canon, title=body.title) + await db.commit() + return {"ok": True, **res} + + +def _purity(metal: str, karat: float) -> float: + if metal == "gold": + return max(0.0, min(1.0, karat / 24)) # karat out of 24 (9/14/18/22/24) + k = karat # silver/platinum: fineness — 925 / 92.5 / 0.925 all → 0.925 + if k > 100: + k /= 1000 + elif k > 1: + k /= 100 + return max(0.0, min(1.0, k)) + + +@router.get("/melt") +async def melt(metal: str = "gold", karat: float = 18, grams: float = 0, + ident=Depends(require_token), db=Depends(get_db)): + """The jewellery module's signature tool — live metal MELT value (the floor you never sell below). + melt = grams × spot/oz × purity, using DealGod's live AU metal prices. Gated on the 'melt' feature.""" + from . import dealgod, packs + if not packs.has_feature("melt"): + return {"ok": False, "reason": "melt not enabled — unlock the jewellery module"} + mp = await dealgod.metal_prices(db) + if not mp: + return {"ok": False, "reason": "metal prices unavailable"} + metal = (metal or "gold").lower() + spot = {"gold": mp.get("gold_aud_oz"), "silver": mp.get("silver_aud_oz"), + "platinum": mp.get("platinum_aud_oz")}.get(metal) + if not spot: + return {"ok": False, "reason": "metal must be gold|silver|platinum"} + purity = _purity(metal, karat) + per_gram = round(spot / 31.1035 * purity, 3) + return {"ok": True, "metal": metal, "karat": karat, "grams": grams, "purity": round(purity, 4), + "per_gram": per_gram, "melt": round(per_gram * (grams or 0), 2), + "spot_oz": spot, "captured_at": mp.get("captured_at")} + + +class PriceGuideIn(BaseModel): + query: str + scope: list[str] | None = None + + +@router.post("/priceguide") +async def priceguide(body: PriceGuideIn, ident=Depends(require_token), db=Depends(get_db)): + """Specialist intel lookup — identify the thing, then compose value + lore (variants/faults/id_keys) + supply. + The price guide a reseller checks BEFORE they buy. Scope defaults to the active modules' domains.""" + from . import dealgod, packs + if not packs.has_feature("value"): + return {"ok": False, "reason": "value not enabled"} + q = (body.query or "").strip() + if not q: + return {"ok": True, "candidate": None} + digits = re.sub(r"\D", "", q) + clue = ({"barcode": digits} if len(digits) >= 12 else {"isbn": digits}) if re.fullmatch(r"[0-9\s-]{8,}", q) else {"text": q} + scope = body.scope or packs.active_scope() + if scope == ["*"]: + scope = None + idr = await dealgod.identify(db, clue, scope=scope) + cands = idr.get("candidates") or [] + if not cands: + return {"ok": True, "candidate": None, "reason": idr.get("reason") or idr.get("status")} + c = cands[0] + val = await dealgod.value(db, ref_type=c.get("ref_type"), ref_id=c.get("ref_id")) + lore = await dealgod.lore(db, c.get("ref_id")) if (c.get("ref_type") == "canon" and packs.has_feature("lore")) else None + sup = None + if c.get("ref_type") == "release": + sup = (await dealgod.supply(db, [c["ref_id"]])).get(str(c["ref_id"])) + return {"ok": True, "candidate": c, "value": val, "lore": lore, "supply": sup, "others": cands[1:4]} + + class InvEditIn(BaseModel): price: float | None = None cost_price: float | None = None @@ -823,6 +947,210 @@ async def system(ident=Depends(require_admin), db=Depends(get_db)): return {"db": info, "tables": tables, "fresh": fresh} +@router.get("/slow-queries") +async def slow_queries(ident=Depends(require_admin), db=Depends(get_db)): + """The Query Monitor replacement — slowest SQL by mean time, straight from pg_stat_statements (the real tool).""" + try: + rows = [dict(r) for r in (await db.execute(text(""" + SELECT left(query, 200) AS query, calls, + round(mean_exec_time::numeric, 2) AS mean_ms, + round(total_exec_time::numeric, 0) AS total_ms, + round(max_exec_time::numeric, 1) AS max_ms, rows + FROM pg_stat_statements + WHERE query NOT ILIKE '%pg_stat_statements%' + AND query NOT ILIKE 'BEGIN%' AND query NOT ILIKE 'COMMIT%' AND query NOT ILIKE 'SET %' + ORDER BY mean_exec_time DESC LIMIT 30 + """))).mappings()] + return {"ok": True, "queries": rows} + except Exception as e: + return {"ok": False, "reason": f"pg_stat_statements unavailable: {e}"} + + +@router.post("/slow-queries/reset") +async def slow_queries_reset(ident=Depends(require_admin), db=Depends(get_db)): + try: + await db.execute(text("SELECT pg_stat_statements_reset()")) + await db.commit() + return {"ok": True} + except Exception as e: + return {"ok": False, "reason": str(e)} + + +@router.get("/payment-providers") +async def payment_providers(ident=Depends(require_token), db=Depends(get_db)): + """Which processors this store can take — the checkout offers exactly these (provider-agnostic: the anti-Ghost).""" + from . import payments + return {"ok": True, "providers": await payments.enabled_providers(db)} + + +class PayIn(BaseModel): + provider: str = "mock" + token: str # the tokenised card from the frontend SDK (never raw card data) + amount: float # dollars + currency: str = "AUD" + reference: str | None = None + + +@router.post("/pay") +async def pay(body: PayIn, ident=Depends(require_admin), db=Depends(get_db)): + """Virtual terminal — charge a tokenised card via ANY configured processor (Square/Stripe/PayPal/mock). + Card data is tokenised client-side; we only ever see the token. Amount in dollars → charged in cents.""" + from . import payments + return await payments.charge(db, body.provider, body.token, int(round(body.amount * 100)), + body.currency, body.reference) + + +# ── Content pages (the WordPress Pages replacement) ─────────────────────────────────────────── +class PageIn(BaseModel): + slug: str + title: str = "" + body: str = "" + published: bool = True + seo_title: str | None = None + seo_description: str | None = None + nav_order: int = 0 + + +@router.get("/pages") +async def pages_list(ident=Depends(require_token), db=Depends(get_db)): + rows = [dict(r) for r in (await db.execute(text( + "SELECT id, slug, title, published, nav_order, updated_at, length(body) AS body_len " + "FROM page ORDER BY nav_order, slug"))).mappings()] + return {"ok": True, "pages": rows} + + +@router.get("/pages/{slug}") +async def page_get(slug: str, ident=Depends(require_token), db=Depends(get_db)): + r = (await db.execute(text("SELECT * FROM page WHERE slug = :s"), {"s": slug})).mappings().first() + return {"ok": True, "page": dict(r) if r else None} + + +@router.post("/pages") +async def page_save(body: PageIn, ident=Depends(require_admin), db=Depends(get_db)): + slug = re.sub(r"[^a-z0-9-]+", "-", body.slug.lower()).strip("-") or "page" + await db.execute(text(""" + INSERT INTO page (slug, title, body, published, seo_title, seo_description, nav_order, updated_at) + VALUES (:s, :t, :b, :p, :st, :sd, :no, now()) + ON CONFLICT (slug) DO UPDATE SET title=:t, body=:b, published=:p, seo_title=:st, + seo_description=:sd, nav_order=:no, updated_at=now()"""), + {"s": slug, "t": body.title, "b": body.body, "p": body.published, + "st": body.seo_title, "sd": body.seo_description, "no": body.nav_order}) + await db.commit() + return {"ok": True, "slug": slug} + + +@router.delete("/pages/{slug}") +async def page_delete(slug: str, ident=Depends(require_admin), db=Depends(get_db)): + await db.execute(text("DELETE FROM page WHERE slug = :s"), {"s": slug}) + await db.commit() + return {"ok": True} + + +# ── 301 redirect map (old WP/Woo URLs → new) — SEO preservation at cutover ───────────────────── +class RedirectRow(BaseModel): + from_path: str + to_path: str + code: int = 301 + + +class RedirectsIn(BaseModel): + redirects: list[RedirectRow] + + +@router.get("/redirects") +async def redirects_list(ident=Depends(require_admin), db=Depends(get_db)): + rows = [dict(r) for r in (await db.execute(text( + "SELECT from_path, to_path, code, hits FROM url_redirect ORDER BY hits DESC, from_path LIMIT 500"))).mappings()] + return {"ok": True, "redirects": rows} + + +@router.post("/redirects") +async def redirects_save(body: RedirectsIn, ident=Depends(require_admin), db=Depends(get_db)): + n = 0 + for r in body.redirects: + fp, tp = r.from_path.strip(), r.to_path.strip() + if not fp or not tp or fp == tp: + continue + if not fp.startswith("/"): + fp = "/" + fp + await db.execute(text("""INSERT INTO url_redirect (from_path, to_path, code) VALUES (:f, :t, :c) + ON CONFLICT (from_path) DO UPDATE SET to_path = :t, code = :c"""), + {"f": fp, "t": tp, "c": r.code or 301}) + n += 1 + await db.commit() + return {"ok": True, "saved": n} + + +@router.delete("/redirects") +async def redirect_delete(from_path: str = Query(...), ident=Depends(require_admin), db=Depends(get_db)): + await db.execute(text("DELETE FROM url_redirect WHERE from_path = :f"), {"f": from_path}) + await db.commit() + return {"ok": True} + + +# ── Discogs Marketplace Manager (manage YOUR listings + orders from StoreGod) ────────────────── +@router.get("/discogs") +async def discogs_status(ident=Depends(require_token), db=Depends(get_db)): + from . import discogs_mp + return await discogs_mp.status(db) + + +@router.get("/discogs/orders") +async def discogs_orders(page: int = 1, ident=Depends(require_token), db=Depends(get_db)): + from . import discogs_mp + return await discogs_mp.orders(db, page) + + +class DiscogsListIn(BaseModel): + status: str = "For Sale" # 'Draft' = safe/not live (use to test); 'For Sale' goes live + price: float | None = None + + +@router.post("/discogs/list/{sku}") +async def discogs_list(sku: str, body: DiscogsListIn, ident=Depends(require_admin), db=Depends(get_db)): + """List a StoreGod item on Discogs. Records only (needs a release_id). Stores the listing id for cross-channel sync.""" + from . import discogs_mp + row = (await db.execute(text( + "SELECT release_id, price::float AS price, condition, sleeve_cond, discogs_listing_id " + "FROM inventory WHERE sku = :s AND store_id = :st"), {"s": sku, "st": ident["store_id"]})).mappings().first() + if not row: + raise HTTPException(404, "not found") + if not row["release_id"]: + return {"ok": False, "error": "no release_id — Discogs listings need a Discogs release"} + if row["discogs_listing_id"]: + return {"ok": False, "error": f"already listed (#{row['discogs_listing_id']})"} + price = body.price if body.price is not None else (row["price"] or 0) + if price <= 0: + return {"ok": False, "error": "set a price first"} + res = await discogs_mp.create_listing(db, row["release_id"], price, row["condition"], row["sleeve_cond"], body.status) + if res.get("ok"): + await db.execute(text("UPDATE inventory SET discogs_listing_id = :l WHERE sku = :s"), + {"l": res["listing_id"], "s": sku}) + await db.commit() + return res + + +@router.delete("/discogs/list/{sku}") +async def discogs_delist(sku: str, ident=Depends(require_admin), db=Depends(get_db)): + from . import discogs_mp + lid = (await db.execute(text("SELECT discogs_listing_id FROM inventory WHERE sku = :s AND store_id = :st"), + {"s": sku, "st": ident["store_id"]})).scalar() + if not lid: + return {"ok": True, "note": "not listed"} + res = await discogs_mp.delete_listing(db, lid) + if res.get("ok"): + await db.execute(text("UPDATE inventory SET discogs_listing_id = NULL WHERE sku = :s"), {"s": sku}) + await db.commit() + return res + + +@router.post("/discogs/sync") +async def discogs_sync(ident=Depends(require_admin), db=Depends(get_db)): + """Pull new Discogs orders → mark sold + record the sale (idempotent). The other half of anti-oversell.""" + from . import discogs_mp + return await discogs_mp.sync_orders(db) + + @router.get("/orders") async def orders(ident=Depends(require_token), db=Depends(get_db)): base = await vault.get_secret(db, "woo_base_url") diff --git a/app/dealgod.py b/app/dealgod.py new file mode 100644 index 0000000..1343f76 --- /dev/null +++ b/app/dealgod.py @@ -0,0 +1,160 @@ +"""DealGod client — RecordGod's single seam to the universal brain (BaseGod). Implements the frozen +DealGod API contract: identify · value · lore · supply · metal-prices (defined + owned in the DealGod +hub repo, mirrored here). Reference consumer: the other StoreGod skins copy this call pattern. + +Key from the vault (`dealgod_api_key`), sent as X-Api-Key. Base URL is env-configurable so we can mock +against a local stub / `:8001` before DG1 allowlists the routes on api.dealgod.pro: + DEALGOD_API_BASE=http://localhost:8001 # dev mock +Endpoints: identify · value · lore · supply. The guard ships default-open, so calls work pre-entitlement. +""" +import asyncio +import os +import httpx + +BASE = os.getenv("DEALGOD_API_BASE", "https://api.dealgod.pro").rstrip("/") + + +async def _key(db): + # per-skin SCOPED customer key (set on the container, e.g. ToolGod's tools-only key) wins; + # else the shared vault key (master/owner). Lets each skin run as its own scoped customer. + k = os.getenv("DEALGOD_API_KEY") + if k: + return k + from . import vault # lazy so this module imports cleanly for the standalone selfcheck + return await vault.get_secret(db, "dealgod_api_key") + + +def _norm_candidate(c: dict) -> dict: + """Frozen contract: candidates carry ref_type:'canon'|'release' + ref_id — records resolve to a Discogs + release_id, NOT a canon_id (SCANGOD's structural point). Echo convenience keys so callers switch on either, + and back-fill ref_type/ref_id from a legacy/direct shape.""" + rt = c.get("ref_type") + if rt is None: + if c.get("release_id") is not None: + c["ref_type"], c["ref_id"], rt = "release", c["release_id"], "release" + elif c.get("canon_id") is not None: + c["ref_type"], c["ref_id"], rt = "canon", c["canon_id"], "canon" + if rt == "release": + c.setdefault("release_id", c.get("ref_id")) + elif rt == "canon": + c.setdefault("canon_id", c.get("ref_id")) + return c + + +async def identify(db, clues: dict, *, images=None, labels=None, hints=None, scope=None, features=None) -> dict: + """POST /api/identify. clues = {barcode?,isbn?,catno?,text?}; images = [b64,…≤4]; labels = [ocr…]; + hints = {brand,size,weight,notes}; scope = DOMAINS (e.g. ['music']). Returns {candidates:[{ref_type,ref_id,slug, + category,display_name,confidence,id_keys,traps,…}]} — normalised so each candidate has canon_id OR release_id set.""" + key = await _key(db) + if not key: + return {"candidates": [], "error": "no_key"} + payload = dict(clues or {}) + if images: + payload["images"] = images + if labels: + payload["labels"] = labels + if hints: + payload["hints"] = hints + body = {"clues": payload} + if scope: + body["scope"] = list(scope) + try: + async with httpx.AsyncClient(timeout=30, headers={"X-Api-Key": key}) as c: + r = await c.post(f"{BASE}/api/identify", json=body) + except Exception as e: + return {"candidates": [], "error": str(e)} + if r.status_code != 200: + return {"candidates": [], "status": r.status_code} + d = r.json() + d["candidates"] = [_norm_candidate(x) for x in d.get("candidates", [])] + return d + + +async def value(db, *, ref_type=None, ref_id=None, canon_id=None, release_id=None, condition=None, region="AU") -> dict | None: + """POST /api/value — frozen primary shape is {ref_type,ref_id} ('canon'|'release'); canon_id/release_id accepted + as convenience aliases. Returns {low,typ,high,currency,as_of,n?,melt_floor?,comps?,supply?,source[]} or None.""" + key = await _key(db) + if not key: + return None + if ref_type is None: # derive the universal ref from whichever alias the caller passed + if release_id is not None: + ref_type, ref_id = "release", release_id + elif canon_id is not None: + ref_type, ref_id = "canon", canon_id + if ref_type is None or ref_id is None: + return None + body = {"ref_type": ref_type, "ref_id": ref_id, "region": region} + if condition: + body["condition"] = condition + try: + async with httpx.AsyncClient(timeout=25, headers={"X-Api-Key": key}) as c: + r = await c.post(f"{BASE}/api/value", json=body) + return r.json() if r.status_code == 200 else None + except Exception: + return None + + +async def lore(db, ref) -> dict | None: + """GET /api/lore/{ref} — {ref} = canon_id (int) OR slug. The harvested resale wiki + (description, tier, variants, faults, id_keys, traps, related) or None.""" + key = await _key(db) + if not key or ref is None: + return None + try: + async with httpx.AsyncClient(timeout=20, headers={"X-Api-Key": key}) as c: + r = await c.get(f"{BASE}/api/lore/{ref}") + return r.json() if r.status_code == 200 else None + except Exception: + return None + + +async def metal_prices(db) -> dict | None: + """GET /api/metal-prices — live AU spot (gold/silver/platinum oz) + per-gram. Powers the jewellery melt tool.""" + key = await _key(db) + if not key: + return None + try: + async with httpx.AsyncClient(timeout=15, headers={"X-Api-Key": key}) as c: + r = await c.get(f"{BASE}/api/metal-prices") + return r.json() if r.status_code == 200 else None + except Exception: + return None + + +async def supply(db, ids: list) -> dict: + """POST /api/supply (LIVE). Batched ≤200; batches run CONCURRENTLY (bounded) so a big buylist + scan isn't a serial network crawl. Returns {: {store_count, discogs_seller_count, …}}; + degrades to {} / drops a failed batch. ponytail: Semaphore(5) caps load — the brain has its own + rate limits + matcher caps; raise it only if buylists get huge and the brain can take it.""" + key = await _key(db) + if not key or not ids: + return {} + batches = [ids[i:i + 200] for i in range(0, len(ids), 200)] + out, sem = {}, asyncio.Semaphore(5) + async with httpx.AsyncClient(timeout=25, headers={"X-Api-Key": key}) as c: + async def _one(batch): + async with sem: + try: + r = await c.post(f"{BASE}/api/supply", json={"ids": batch}) + if r.status_code == 200: + out.update(r.json().get("results", {})) # single-threaded asyncio → no race + except Exception: + pass + await asyncio.gather(*(_one(b) for b in batches)) + return out + + +def _selfcheck(): + rel = _norm_candidate({"ref_type": "release", "ref_id": 12345, "display_name": "X"}) + assert rel["release_id"] == 12345 and rel["ref_type"] == "release" + can = _norm_candidate({"ref_type": "canon", "ref_id": 678, "slug": "roland-jp-8000"}) + assert can["canon_id"] == 678 + legacy = _norm_candidate({"release_id": 999}) # legacy/direct shape → derives ref_type/ref_id + assert legacy["ref_type"] == "release" and legacy["ref_id"] == 999 + legacy_c = _norm_candidate({"canon_id": 42}) + assert legacy_c["ref_type"] == "canon" and legacy_c["ref_id"] == 42 + print("ok") + + +if __name__ == "__main__": + _selfcheck() diff --git a/app/discogs_mp.py b/app/discogs_mp.py new file mode 100644 index 0000000..f1627e8 --- /dev/null +++ b/app/discogs_mp.py @@ -0,0 +1,214 @@ +"""Discogs Marketplace Manager — manage YOUR Discogs listings + orders from StoreGod (the WowPlatter version, but +heaps better: one source of truth, cross-channel inventory, DealGod pricing). Uses the seller's personal access token +(vault `discogs_token`) — authorises managing your own inventory/orders. Discogs rate-limits ~60/min authenticated. + +Marketplace API: GET /users/{u}/inventory · POST/DELETE /marketplace/listings[/{id}] · GET /marketplace/orders[/{id}] +· GET /marketplace/price_suggestions/{release_id}. Condition strings must be Discogs' EXACT values (mapped below). +""" +import httpx +try: + from sqlalchemy import text # guarded so the standalone condition-map selfcheck runs without sqlalchemy +except ImportError: + text = None + +DISCOGS = "https://api.discogs.com" +UA = "StoreGod/1.0 +https://recordgod.com" + +# Goldmine/loose grades → Discogs' EXACT condition strings (the API rejects anything else) +_COND = { + "M": "Mint (M)", "MINT": "Mint (M)", + "NM": "Near Mint (NM or M-)", "M-": "Near Mint (NM or M-)", "NEARMINT": "Near Mint (NM or M-)", + "VG+": "Very Good Plus (VG+)", "VGPLUS": "Very Good Plus (VG+)", "VG +": "Very Good Plus (VG+)", + "VG": "Very Good (VG)", "G+": "Good Plus (G+)", "GPLUS": "Good Plus (G+)", + "G": "Good (G)", "GOOD": "Good (G)", "F": "Fair (F)", "FAIR": "Fair (F)", "P": "Poor (P)", "POOR": "Poor (P)", +} + + +def discogs_condition(v, default="Very Good Plus (VG+)"): + if not v: + return default + k = str(v).strip().upper() + if k in _COND: + return _COND[k] + k2 = k.replace("(", "").replace(")", "").replace(" ", "") + return _COND.get(k2, _COND.get(k.split()[0] if k.split() else k, default)) + + +async def _auth(db): + from . import vault + tok = await vault.get_secret(db, "discogs_token") + return tok + + +async def _req(tok, method, path, **kw): + async with httpx.AsyncClient(timeout=25, headers={ + "Authorization": f"Discogs token={tok}", "User-Agent": UA}) as c: + return await c.request(method, DISCOGS + path, **kw) + + +async def seller(db): + tok = await _auth(db) + if not tok: + return None + r = await _req(tok, "GET", "/oauth/identity") + return r.json().get("username") if r.status_code == 200 else None + + +async def status(db): + """Seller + live counts — the cockpit header.""" + tok = await _auth(db) + if not tok: + return {"ok": False, "reason": "no Discogs token (add it in Connections)"} + me = await _req(tok, "GET", "/oauth/identity") + if me.status_code != 200: + return {"ok": False, "reason": f"token rejected ({me.status_code})"} + u = me.json()["username"] + inv = await _req(tok, "GET", f"/users/{u}/inventory", params={"per_page": 1, "status": "For Sale"}) + orders = await _req(tok, "GET", "/marketplace/orders", params={"per_page": 1}) + return {"ok": True, "seller": u, + "listings": inv.json().get("pagination", {}).get("items", 0) if inv.status_code == 200 else None, + "orders": orders.json().get("pagination", {}).get("items", 0) if orders.status_code == 200 else None} + + +async def orders(db, page=1): + tok = await _auth(db) + if not tok: + return {"ok": False, "reason": "no token"} + r = await _req(tok, "GET", "/marketplace/orders", params={"per_page": 25, "page": page, "sort": "last_activity"}) + if r.status_code != 200: + return {"ok": False, "reason": f"HTTP {r.status_code}"} + d = r.json() + return {"ok": True, "orders": [{"id": o["id"], "status": o["status"], + "total": o.get("total", {}).get("value"), "buyer": (o.get("buyer") or {}).get("username"), + "created": o.get("created"), "items": len(o.get("items", []))} for o in d.get("orders", [])], + "pages": d.get("pagination", {}).get("pages", 1)} + + +async def price_suggestions(db, release_id): + tok = await _auth(db) + if not tok: + return None + r = await _req(tok, "GET", f"/marketplace/price_suggestions/{release_id}") + return r.json() if r.status_code == 200 else None + + +async def create_listing(db, release_id, price, condition, sleeve=None, status="Draft", comments=None): + """List one item. status='Draft' is SAFE (not live/sellable) — use it to test; 'For Sale' goes live.""" + tok = await _auth(db) + if not tok: + return {"ok": False, "error": "no token"} + body = {"release_id": int(release_id), "condition": discogs_condition(condition), + "price": round(float(price), 2), "status": status} + if sleeve: + body["sleeve_condition"] = discogs_condition(sleeve) + if comments: + body["comments"] = comments + r = await _req(tok, "POST", "/marketplace/listings", json=body) + if r.status_code in (200, 201): + return {"ok": True, "listing_id": r.json().get("listing_id")} + return {"ok": False, "error": r.text[:200], "status": r.status_code} + + +async def update_listing(db, listing_id, **fields): + tok = await _auth(db) + if not tok: + return {"ok": False, "error": "no token"} + if "condition" in fields: + fields["condition"] = discogs_condition(fields["condition"]) + r = await _req(tok, "POST", f"/marketplace/listings/{listing_id}", json=fields) + return {"ok": r.status_code in (200, 204), "status": r.status_code} + + +async def delete_listing(db, listing_id): + tok = await _auth(db) + if not tok: + return {"ok": False, "error": "no token"} + r = await _req(tok, "DELETE", f"/marketplace/listings/{listing_id}") + return {"ok": r.status_code in (200, 204), "status": r.status_code} + + +async def delist_skus(db, skus): + """Anti-oversell: any of these just-sold skus that have a LIVE Discogs listing → delist it. Best-effort — + a Discogs hiccup must NEVER fail the sale (the sale's already committed when this runs).""" + skus = [s for s in (skus or []) if s] + if not skus: + return 0 + rows = (await db.execute(text( + "SELECT sku, discogs_listing_id FROM inventory WHERE sku = ANY(:s) AND discogs_listing_id IS NOT NULL"), + {"s": skus})).mappings().all() + n = 0 + for r in rows: + try: + if (await delete_listing(db, r["discogs_listing_id"])).get("ok"): + await db.execute(text("UPDATE inventory SET discogs_listing_id = NULL WHERE sku = :s"), {"s": r["sku"]}) + n += 1 + except Exception: + pass + if n: + await db.commit() + return n + + +async def sync_orders(db): + """Pull Discogs marketplace orders → mark the matching StoreGod item sold + record the sale. Idempotent + (skips orders already imported via sales.discogs_order_id). The other half of anti-oversell: sell on Discogs, + it comes off the StoreGod floor too.""" + tok = await _auth(db) + if not tok: + return {"ok": False, "reason": "no token"} + r = await _req(tok, "GET", "/marketplace/orders", + params={"per_page": 50, "sort": "last_activity", "sort_order": "desc"}) + if r.status_code != 200: + return {"ok": False, "reason": f"HTTP {r.status_code}"} + seen = {str(x[0]) for x in (await db.execute( + text("SELECT discogs_order_id FROM sales WHERE discogs_order_id IS NOT NULL"))).all()} + imported = [] + for o in r.json().get("orders", []): + oid = str(o["id"]) + if oid in seen: + continue + lines, total = [], 0.0 + for it in o.get("items", []): + lid = it.get("id") or (it.get("listing") or {}).get("id") + rid = (it.get("release") or {}).get("id") + price = float((it.get("price") or {}).get("value") or 0) + total += price + inv = (await db.execute(text( + "SELECT sku, title FROM inventory WHERE (discogs_listing_id = :l OR (:l IS NULL AND release_id = :r AND in_stock)) " + "AND store_id = 1 LIMIT 1"), {"l": lid, "r": rid})).mappings().first() + lines.append({"sku": inv["sku"] if inv else None, "rid": rid, "price": price, + "title": (inv and inv["title"]) or (it.get("release") or {}).get("description")}) + ototal = float((o.get("total") or {}).get("value") or total) + st = o.get("status") or "" + paid = "paid" if any(k in st for k in ("Payment Received", "Shipped", "Merged")) else "pending" + sale_id = (await db.execute(text(""" + INSERT INTO sales (sale_number, subtotal, tax_amount, total, status, payment_method, payment_status, + amount_paid, discogs_order_id, notes, sale_date, created_at) + VALUES (:sn, :t, 0, :t, 'publish', 'discogs', :ps, :t, :oid, :notes, now(), now()) RETURNING id"""), + {"sn": f"DG{oid}", "t": ototal, "ps": paid, "oid": oid, + "notes": f"Discogs order {oid} · {st} · buyer {(o.get('buyer') or {}).get('username') or '?'}"})).first()[0] + matched = 0 + for ln in lines: + await db.execute(text("""INSERT INTO sale_items (sale_id, sku, release_id, item_name, qty, unit_price, line_total) + VALUES (:s, :sku, :rid, :nm, 1, :p, :p)"""), + {"s": sale_id, "sku": ln["sku"], "rid": ln["rid"], "nm": ln["title"], "p": ln["price"]}) + if ln["sku"]: + await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now(), " + "discogs_listing_id=NULL WHERE sku=:s AND store_id=1"), {"s": ln["sku"]}) + matched += 1 + imported.append({"order": oid, "status": st, "items": len(lines), "matched": matched}) + await db.commit() + return {"ok": True, "count": len(imported), "imported": imported} + + +def _selfcheck(): + assert discogs_condition("VG+") == "Very Good Plus (VG+)" + assert discogs_condition("nm") == "Near Mint (NM or M-)" + assert discogs_condition("Mint (M)") == "Mint (M)" + assert discogs_condition(None) == "Very Good Plus (VG+)" + assert discogs_condition("garbage") == "Very Good Plus (VG+)" # safe default + print("ok") + + +if __name__ == "__main__": + _selfcheck() diff --git a/app/main.py b/app/main.py index 4a08dc5..8bcefca 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,9 @@ +import logging import os import pathlib +log = logging.getLogger("recordgod") + # Load .env FIRST so auth/db/vault see their keys at import time. _ENV = pathlib.Path(__file__).resolve().parent.parent / ".env" if _ENV.exists(): @@ -10,9 +13,9 @@ if _ENV.exists(): _k, _v = _ln.split("=", 1) os.environ.setdefault(_k.strip(), _v.strip()) -from fastapi import FastAPI # noqa: E402 +from fastapi import FastAPI, Request # noqa: E402 from fastapi.staticfiles import StaticFiles # noqa: E402 -from fastapi.responses import FileResponse # noqa: E402 +from fastapi.responses import FileResponse, RedirectResponse, Response, PlainTextResponse # noqa: E402 from . import __version__ # noqa: E402 from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402 @@ -110,6 +113,20 @@ _STARTUP_DDL = [ "ALTER TABLE inventory ADD COLUMN IF NOT EXISTS cost_price numeric(10,2)", "ALTER TABLE inventory ADD COLUMN IF NOT EXISTS cost_source text", # e.g. 'rarewaves #592619' "ALTER TABLE inventory ADD COLUMN IF NOT EXISTS condition_type text NOT NULL DEFAULT 'used'", # 'new' | 'used' + "ALTER TABLE inventory ADD COLUMN IF NOT EXISTS canon_id bigint", # DealGod canon ref for non-record goods (records use release_id) — the StoreGod generalisation + "ALTER TABLE inventory ADD COLUMN IF NOT EXISTS discogs_listing_id bigint", # cross-channel: this item's live Discogs marketplace listing + "ALTER TABLE sales ADD COLUMN IF NOT EXISTS discogs_order_id text", # imported Discogs order (idempotency key for order sync) + "CREATE INDEX IF NOT EXISTS inventory_discogs_listing_idx ON inventory (discogs_listing_id) WHERE discogs_listing_id IS NOT NULL", + # content pages (the WordPress Pages replacement — About/Shipping/Returns/Privacy/…) + """CREATE TABLE IF NOT EXISTS page ( + id bigserial PRIMARY KEY, slug text UNIQUE NOT NULL, title text NOT NULL DEFAULT '', + body text NOT NULL DEFAULT '', published boolean NOT NULL DEFAULT true, + seo_title text, seo_description text, nav_order int NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now())""", + # 301 redirect map — preserve SEO/bookmarks when cutting over from old WP/Woo URLs + """CREATE TABLE IF NOT EXISTS url_redirect ( + from_path text PRIMARY KEY, to_path text NOT NULL, code int NOT NULL DEFAULT 301, + hits int NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now())""", # normalised-barcode lookup (DB barcodes stored inconsistently: '5 018775 901762' vs clean digits) "CREATE INDEX IF NOT EXISTS disc_rel_id_barcode_norm ON disc_release_identifier " "(regexp_replace(value,'[^0-9]','','g')) WHERE type='Barcode'", @@ -123,15 +140,27 @@ _STARTUP_DDL = [ "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama text", "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_radius numeric", "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_color text", + "ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_extent numeric", # east/west coves: limit to the cyc section ] @app.on_event("startup") async def _ensure_tables(): - # lazy migrations on deploy (store_config + POS columns/sequences) - async with engine.begin() as conn: - for _stmt in _STARTUP_DDL: - await conn.execute(_sqltext(_stmt)) + # Lazy idempotent migrations on deploy. Each statement runs in its OWN transaction so a + # single failure (e.g. an ALTER on a table a prior step hasn't created yet) is isolated and + # logged — it can't abort the whole batch and boot the app with NO schema. All statements are + # IF-NOT-EXISTS/idempotent, so a skipped one is simply retried (and may succeed) on next boot. + ok = failed = 0 + for _stmt in _STARTUP_DDL: + try: + async with engine.begin() as conn: + await conn.execute(_sqltext(_stmt)) + ok += 1 + except Exception as e: + failed += 1 + log.warning("startup DDL skipped: %s … — %s", _stmt[:70].replace("\n", " "), e) + if failed: + log.warning("startup DDL: %d applied, %d skipped (see above)", ok, failed) @app.get("/healthz") @@ -139,6 +168,50 @@ async def healthz(): return {"ok": True, "service": "recordgod", "version": __version__} +async def _site_base(request: Request) -> str: + try: + async with engine.connect() as conn: + su = (await conn.execute(_sqltext( + "SELECT setting_value FROM sales_setting WHERE setting_key='store_url' AND is_active"))).scalar() + if su: + return su.rstrip("/") + except Exception: + pass + return str(request.base_url).rstrip("/") + + +@app.get("/robots.txt", include_in_schema=False) +async def robots(request: Request): + base = await _site_base(request) + return PlainTextResponse(f"User-agent: *\nAllow: /\nSitemap: {base}/sitemap.xml\n") + + +@app.get("/sitemap.xml", include_in_schema=False) +async def sitemap(request: Request): + base = await _site_base(request) + paths = ["/", "/shop"] + try: + async with engine.connect() as conn: + for (slug,) in (await conn.execute(_sqltext("SELECT slug FROM page WHERE published"))).all(): + paths.append(f"/shop/page/{slug}") + for (rid,) in (await conn.execute(_sqltext( + "SELECT DISTINCT release_id FROM inventory WHERE in_stock AND release_id IS NOT NULL LIMIT 10000"))).all(): + paths.append(f"/release/{rid}") + except Exception: + pass + locs = "".join(f"{base}{p}" for p in paths) + xml = ('' + '' + locs + "") + return Response(xml, media_type="application/xml") + + +@app.get("/pack") +async def pack(): + """Public — the active brand + unlocked modules (so the login screen brands itself before auth). Non-sensitive.""" + from . import packs + return packs.info() + + _ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -158,8 +231,45 @@ app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET" # entity browse pages — records.html reads the kind+value from the path (/artist/9, /genre/House…) for _ent in ("artist", "label", "genre", "style"): app.add_api_route(f"/{_ent}/{{val}}", _page("records.html"), methods=["GET"], include_in_schema=False) -# the login landing too (no-cache), matched before the "/" static mount below -app.add_api_route("/", _page("index.html"), methods=["GET"], include_in_schema=False) +# "/" honours the homepage setting (Store Settings → Homepage): storefront → /shop, 3d → /store, +# else the index.html landing. Defaults safe (landing) if unset/unreadable. +async def _home(): + hp = None + try: + async with engine.connect() as conn: + hp = (await conn.execute(_sqltext( + "SELECT setting_value FROM sales_setting WHERE setting_key='homepage' AND is_active"))).scalar() + except Exception: + pass + if hp == "storefront": + return RedirectResponse("/shop") + if hp == "3d": + return RedirectResponse("/store") + return FileResponse(_ROOT / "site" / "index.html", headers={"Cache-Control": "no-cache, must-revalidate"}) + + +app.add_api_route("/", _home, methods=["GET"], include_in_schema=False) + + +# 404 → check the redirect map (old WP/Woo URLs → new ones) before giving up. Keeps SEO + bookmarks alive at cutover. +from starlette.exceptions import HTTPException as _StarletteHTTPException # noqa: E402 +from fastapi.responses import JSONResponse # noqa: E402 + + +@app.exception_handler(_StarletteHTTPException) +async def _on_http_exc(request, exc): + if exc.status_code == 404: + try: + async with engine.connect() as conn: + row = (await conn.execute(_sqltext( + "UPDATE url_redirect SET hits = hits + 1 WHERE from_path = :p RETURNING to_path, code"), + {"p": request.url.path})).first() + await conn.commit() + if row: + return RedirectResponse(row[0], status_code=row[1] or 301) + except Exception: + pass + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) # Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site. if (_ROOT / "webstore").is_dir(): diff --git a/app/navigator_routes.py b/app/navigator_routes.py index c6c13dc..7deda1b 100644 --- a/app/navigator_routes.py +++ b/app/navigator_routes.py @@ -36,7 +36,7 @@ async def store_layout(space_id: int | None = None, ident=Depends(require_token) """), {"s": space_id})).mappings().first() racks = [dict(r) for r in (await db.execute(text(""" SELECT r.id, r.name, r.pos_x::float AS x, r.pos_z::float AS z, - r.rotation_y::float AS rot, r.direction, + r.rotation_y::float AS rot, r.direction, r.attach_wall, rt.type AS rack_type, rt.name AS type_name, rt.width::float AS w, rt.depth::float AS d, coalesce(rt.levels,1)::int AS levels, (SELECT count(*) FROM virtual_crate c WHERE c.rack_id = r.id AND c.visible = 'y') AS crates, @@ -88,6 +88,8 @@ class RackEditIn(BaseModel): pos_z: float | None = None direction: str | None = None # forward | back | left | right rotation_y: float | None = None # fine rotation (degrees) + space_id: int | None = None # move the whole rack (+ its crates) to another room + attach_wall: str | None = None # '' to free a wall-attached rack (the editor folds facing into rotation_y) @router.post("/rack/{rack_id}") @@ -97,6 +99,9 @@ async def rack_edit(rack_id: int, body: RackEditIn, ident=Depends(require_token) return {"ok": True, "unchanged": True} sets = ", ".join(f"{k} = :{k}" for k in fields) + ", updated_at = now()" r = await db.execute(text(f"UPDATE virtual_rack SET {sets} WHERE id = :i"), {**fields, "i": rack_id}) + if "space_id" in fields: # the rack's crates are slot-positioned children — keep them in the same room + await db.execute(text("UPDATE virtual_crate SET space_id = :s WHERE rack_id = :i"), + {"s": fields["space_id"], "i": rack_id}) await db.commit() if not r.rowcount: raise HTTPException(404, "rack not found") diff --git a/app/packs.py b/app/packs.py new file mode 100644 index 0000000..25ecb6e --- /dev/null +++ b/app/packs.py @@ -0,0 +1,127 @@ +"""The GODVERSE modules — canonical naming per GODVERSE.md (the brand bible; lanes name to match it). + +StoreGod is the BASE store-management app (vanilla: inventory, POS, store ops, universal identify+value over the +BaseGod brain — any goods). Each MODULE (RecordGod, ToolGod, VaultGod…) is a specialist ADD-ON unlocked on top, +deepening the store for one shopper-world. A store unlocks the modules for what it sells — MULTIPLE at once +(a pawnbroker enables records + tools + jewellery). Enabled via `STOREGOD_MODULES` (csv); the key's scope/features +on the DealGod side gate access + billing. Branding: one module → that BrandGod; many/none → "StoreGod". + +Umbrella by shopper-world + data source (GODVERSE.md §naming): media (TMDb video), tech (electronics), +vault (graded collectibles = comics+cards+collectible toys), kid (family/kids resale). Cross-cutting FEATURES +(scangod/meltgod/stagegod/comps/lore/history) are capabilities, NOT modules — they ride on a key's `features`. +""" +import os +from dataclasses import dataclass + +BASE_BRAND = "StoreGod" +BASE_FEATURES = ("identify", "value", "inventory", "pos", "supply") + + +@dataclass(frozen=True) +class Module: + key: str # STOREGOD_MODULES token + brand: str # BrandGod name shown when this is the only module unlocked + domains: tuple # the DealGod scope domain(s) it unlocks (umbrellas carry several) + condition_vocab: str # grading vocab + adds: tuple # specialist features layered onto the base + threed: bool + + +# Roster per GODVERSE.md. domains use DG1's published scope vocab +# (apparel·books·comics·computing·games·instruments·jewellery·music·phones·photo·tcg·tools·toys·video). +MODULES = { + "records": Module("records", "RecordGod", ("music",), "goldmine", ("lore", "discogs_intake", "wishlist", "heal"), True), + "media": Module("media", "MediaGod", ("video",), "disc", ("lore",), False), + "books": Module("books", "BookGod", ("books",), "generic", ("lore",), False), + "tech": Module("tech", "TechGod", ("computing", "phones", "photo"), "grade", ("comps", "lore"), False), + "tools": Module("tools", "ToolGod", ("tools",), "tested", ("lore",), False), + "play": Module("play", "PlayGod", ("instruments",), "gear", ("lore",), True), + "games": Module("games", "GameGod", ("games",), "CIB", ("comps", "lore"), False), + "jewellery": Module("jewellery", "JewelGod", ("jewellery",), "hallmark", ("melt",), False), + "vault": Module("vault", "VaultGod", ("comics", "tcg", "toys"), "graded", ("lore", "comps"), False), + "kids": Module("kids", "KidGod", ("toys", "apparel"), "generic", ("comps",), False), + "kicks": Module("kicks", "KicksGod", ("apparel",), "grade", ("comps",), False), + "drip": Module("drip", "DripGod", ("apparel",), "generic", ("comps",), False), +} + +# legacy STOREGOD_PACK / module aliases → canonical key (back-compat for running containers + folded gods) +ALIASES = {"store": None, "": None, "instruments": "play", "computing": "tech", "phones": "tech", + "comics": "vault", "cards": "vault", "tcg": "vault", "toys": "vault", "jewel": "jewellery"} + + +def _resolve(token: str): + token = ALIASES.get(token, token) + return MODULES.get(token) if token else None + + +def active_modules() -> list: + """The unlocked modules. STOREGOD_MODULES (csv) preferred; falls back to legacy single STOREGOD_PACK + ('store'/'' = vanilla base, no module). Unknown/folded tokens resolve via ALIASES.""" + env = os.getenv("STOREGOD_MODULES") + tokens = ([t.strip() for t in env.split(",")] if env is not None + else [os.getenv("STOREGOD_PACK", "records")]) + out, seen = [], set() + for t in tokens: + m = _resolve(t.strip()) + if m and m.key not in seen: + out.append(m) + seen.add(m.key) + return out + + +def brand() -> str: + m = active_modules() + return m[0].brand if len(m) == 1 else BASE_BRAND + + +def active_scope() -> list: + m = active_modules() + if not m: + return ["*"] # no module = StoreGod base = every domain + return sorted({d for mod in m for d in mod.domains}) # union of all unlocked modules' domains + + +def features() -> set: + f = set(BASE_FEATURES) + for m in active_modules(): + f |= set(m.adds) + return f + + +def has_module(key: str) -> bool: + mod = _resolve(key) + return bool(mod) and any(m.key == mod.key for m in active_modules()) + + +def has_feature(feat: str) -> bool: + return feat in features() + + +def threed() -> bool: + return any(m.threed for m in active_modules()) + + +def info() -> dict: + return {"brand": brand(), "base": BASE_BRAND, "modules": [m.key for m in active_modules()], + "scope": active_scope(), "features": sorted(features()), "threed": threed()} + + +def _selfcheck(): + assert "identify" in features() and "value" in features() # base always on + os.environ["STOREGOD_MODULES"] = "records,tools" + assert has_module("records") and has_module("tools") + assert brand() == "StoreGod" and set(active_scope()) == {"music", "tools"} + os.environ["STOREGOD_MODULES"] = "records" + assert brand() == "RecordGod" and "discogs_intake" in features() + os.environ["STOREGOD_MODULES"] = "vault" # umbrella → multi-domain + assert brand() == "VaultGod" and set(active_scope()) == {"comics", "tcg", "toys"} + os.environ["STOREGOD_MODULES"] = "comics" # folded alias → vault + assert has_module("vault") + os.environ["STOREGOD_MODULES"] = "" + assert brand() == "StoreGod" and active_scope() == ["*"] + del os.environ["STOREGOD_MODULES"] + print("ok") + + +if __name__ == "__main__": + _selfcheck() diff --git a/app/payments.py b/app/payments.py new file mode 100644 index 0000000..c306502 --- /dev/null +++ b/app/payments.py @@ -0,0 +1,141 @@ +"""Provider-agnostic payments — StoreGod's replacement for the WooCommerce payment plugins (Square/PayPal/Stripe). + +Every modern processor follows the SAME shape: the frontend SDK tokenises the card (raw card data never touches our +server → PCI SAQ A), the backend charges the token, optional refund/webhook. So they all implement ONE interface and +adding a processor = filling in one function. This is the anti-Ghost: bring whatever processor you already use. + +The merchant's OWN keys live in the vault; money flows merchant → their processor → their bank. StoreGod never holds +funds. Amounts are integer CENTS (minor units), AUD by default. + +Tested live here: `mock` (fake money, proves the flow). `square` is real + ready (the store's prod creds are set — +needs a Square application_id for the web SDK before a live web charge). `stripe`/`paypal` are coded to spec but +marked UNTESTED until their keys are added — the interface is the point, each is one function. +""" +import os +from uuid import uuid4 + +import httpx + +SQUARE_VERSION = "2024-12-18" + + +def _vault(): + from . import vault # lazy so this module imports cleanly for the standalone selfcheck + return vault + + +# ── adapters: charge(db, token, cents, currency, ref) -> {ok, payment_id?, status?, provider, error?} ── + +async def _mock_charge(db, token, cents, currency, ref): + """Test provider — no money, proves the order→charge→paid flow. token 'mock-decline' simulates a decline.""" + if token == "mock-decline": + return {"ok": False, "provider": "mock", "error": "card declined (mock)"} + return {"ok": True, "provider": "mock", "payment_id": "mock_" + (ref or uuid4().hex[:8]), "status": "COMPLETED"} + + +async def _square_charge(db, token, cents, currency, ref): + tok = await _vault().get_secret(db, "square_access_token") + if not tok: + return {"ok": False, "provider": "square", "error": "square not configured"} + env = (await _vault().get_secret(db, "square_environment") or "production").lower() + base = "https://connect.squareupsandbox.com" if env.startswith("sand") else "https://connect.squareup.com" + loc = await _vault().get_secret(db, "square_location_id") + body = {"source_id": token, "idempotency_key": (ref or uuid4().hex)[:45], + "amount_money": {"amount": cents, "currency": currency}} + if loc: + body["location_id"] = loc + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{base}/v2/payments", json=body, + headers={"Authorization": f"Bearer {tok}", "Square-Version": SQUARE_VERSION}) + d = r.json() + except Exception as e: + return {"ok": False, "provider": "square", "error": str(e)} + if r.status_code == 200: + p = d["payment"] + return {"ok": True, "provider": "square", "payment_id": p["id"], "status": p["status"]} + return {"ok": False, "provider": "square", "error": (d.get("errors") or [{}])[0].get("detail", r.text[:200])} + + +async def _stripe_charge(db, token, cents, currency, ref): + # UNTESTED — needs stripe_secret_key. PaymentIntent create+confirm with a payment_method token from Stripe.js. + sk = await _vault().get_secret(db, "stripe_secret_key") + if not sk: + return {"ok": False, "provider": "stripe", "error": "stripe not configured"} + data = {"amount": str(cents), "currency": currency.lower(), "payment_method": token, + "confirm": "true", "automatic_payment_methods[enabled]": "true", + "automatic_payment_methods[allow_redirects]": "never"} + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post("https://api.stripe.com/v1/payment_intents", data=data, + headers={"Authorization": f"Bearer {sk}"}, + params={"idempotency_key": ref} if ref else None) + d = r.json() + except Exception as e: + return {"ok": False, "provider": "stripe", "error": str(e)} + if r.status_code == 200 and d.get("status") in ("succeeded", "requires_capture"): + return {"ok": True, "provider": "stripe", "payment_id": d["id"], "status": d["status"]} + return {"ok": False, "provider": "stripe", "error": (d.get("error") or {}).get("message", r.text[:200])} + + +async def _paypal_charge(db, token, cents, currency, ref): + # UNTESTED — needs paypal_client_id/secret. `token` = a PayPal order id already approved client-side; we capture it. + cid = await _vault().get_secret(db, "paypal_client_id") + sec = await _vault().get_secret(db, "paypal_secret") + if not (cid and sec): + return {"ok": False, "provider": "paypal", "error": "paypal not configured"} + sandbox = (await _vault().get_secret(db, "paypal_environment") or "live").lower().startswith("sand") + base = "https://api-m.sandbox.paypal.com" if sandbox else "https://api-m.paypal.com" + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{base}/v2/checkout/orders/{token}/capture", auth=(cid, sec), + headers={"Content-Type": "application/json"}) + d = r.json() + except Exception as e: + return {"ok": False, "provider": "paypal", "error": str(e)} + if r.status_code in (200, 201) and d.get("status") == "COMPLETED": + return {"ok": True, "provider": "paypal", "payment_id": d["id"], "status": d["status"]} + return {"ok": False, "provider": "paypal", "error": d.get("message", r.text[:200])} + + +CHARGERS = {"mock": _mock_charge, "square": _square_charge, "stripe": _stripe_charge, "paypal": _paypal_charge} + + +async def charge(db, provider, token, cents, currency="AUD", ref=None): + """Dispatch a charge to the chosen provider. cents = integer minor units.""" + fn = CHARGERS.get((provider or "").lower()) + if not fn: + return {"ok": False, "error": f"unknown provider '{provider}'"} + if not isinstance(cents, int) or cents <= 0: + return {"ok": False, "error": "amount must be a positive integer (cents)"} + return await fn(db, token, cents, currency, ref) + + +async def enabled_providers(db): + """Which processors this store has keys for — the checkout offers exactly these. The `mock` provider is + a footgun on a public checkout (free 'paid' orders), so it's gated behind STOREGOD_ALLOW_MOCK_PAY=1 — + on for pre-launch testing, OFF before the storefront goes public.""" + out = ["mock"] if os.getenv("STOREGOD_ALLOW_MOCK_PAY") == "1" else [] + if await _vault().get_secret(db, "square_access_token"): + out.append("square") + if await _vault().get_secret(db, "stripe_secret_key"): + out.append("stripe") + if await _vault().get_secret(db, "paypal_client_id"): + out.append("paypal") + return out + + +def _selfcheck(): + import asyncio + + async def t(): + assert (await charge(None, "mock", "tok", 1999))["ok"] is True + assert (await charge(None, "mock", "mock-decline", 1999))["ok"] is False + assert (await charge(None, "nope", "x", 100))["ok"] is False # unknown provider + assert (await charge(None, "mock", "x", 0))["ok"] is False # bad amount + print("ok") + asyncio.run(t()) + + +if __name__ == "__main__": + _selfcheck() diff --git a/app/sales_routes.py b/app/sales_routes.py index 70977f0..b483682 100644 --- a/app/sales_routes.py +++ b/app/sales_routes.py @@ -114,6 +114,12 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get WHERE sku = ANY(:skus) AND store_id = 1 """), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]}) await db.commit() + if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort) + try: + from . import discogs_mp + await discogs_mp.delist_skus(db, [li.sku for li in body.items]) + except Exception: + pass return {"ok": True, "sale_id": sale_id, "sale_number": sale_number, "total": total, "amount_paid": amount_paid, "balance": round(total - amount_paid, 2), "status": status} @@ -276,18 +282,25 @@ async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Dep return {"ok": True, "manual_active": row[0]} -# ── Settings (currency / tax / default discount) ───────────────────────────────────────────── +# ── Store Settings — the WordPress General/Reading replacement (one screen, kv-backed) ───────── +_SETTING_DEFAULTS = { + "store_name": "RecordGod", "store_tagline": "", "store_logo": "", "store_address": "", + "contact_email": "", "contact_phone": "", "abn": "", "timezone": "Australia/Sydney", + "store_url": "", "social_instagram": "", "social_facebook": "", + "currency_symbol": "$", "tax_rate": "0", "tax_type": "exclusive", "discount_rate": "0", + "policy_returns": "", "policy_shipping": "", "policy_privacy": "", + "receipt_footer": "thanks for digging", "homepage": "storefront", + "seo_title": "", "seo_description": "", + "meta_pixel_id": "", "umami_website_id": "", "umami_src": "", "ga4_id": "", +} +# some keys are stored under legacy names in the kv table +_SETTING_ALIASES = {"tax_rate": "default_tax_rate", "discount_rate": "default_discount_rate"} + + async def _settings(db): rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active")) s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()} - return {"currency_symbol": s.get("currency_symbol", "$"), - "tax_rate": s.get("default_tax_rate", s.get("tax_rate", "0")), - "discount_rate": s.get("default_discount_rate", "0"), - "tax_type": s.get("tax_type", "exclusive"), - "store_name": s.get("store_name", "RecordGod"), - "store_address": s.get("store_address", ""), - "receipt_footer": s.get("receipt_footer", "thanks for digging"), - "store_logo": s.get("store_logo", "")} + return {k: s.get(_SETTING_ALIASES.get(k, k), s.get(k, d)) for k, d in _SETTING_DEFAULTS.items()} @router.get("/settings") @@ -297,17 +310,14 @@ async def get_settings(ident=Depends(require_token), db=Depends(get_db)): @router.post("/settings") async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)): - m = {"currency_symbol": body.get("currency_symbol"), "default_tax_rate": body.get("tax_rate"), - "default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type"), - "store_name": body.get("store_name"), "store_address": body.get("store_address"), - "receipt_footer": body.get("receipt_footer"), "store_logo": body.get("store_logo")} - for k, v in m.items(): - if v is None: + for k, v in body.items(): + if k not in _SETTING_DEFAULTS or v is None: continue await db.execute(text(""" - INSERT INTO sales_setting (setting_key, setting_value, updated_at) VALUES (:k, :v, now()) - ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now() - """), {"k": k, "v": str(v)}) + INSERT INTO sales_setting (setting_key, setting_value, is_active, updated_at) + VALUES (:k, :v, true, now()) + ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, is_active = true, updated_at = now() + """), {"k": _SETTING_ALIASES.get(k, k), "v": str(v)}) await db.commit() return {"ok": True} diff --git a/app/shop_routes.py b/app/shop_routes.py index 57d8f18..6541009 100644 --- a/app/shop_routes.py +++ b/app/shop_routes.py @@ -1,9 +1,13 @@ import asyncio +import html import json import re +import secrets +from datetime import datetime import httpx from fastapi import APIRouter, Depends, Query, HTTPException, Header +from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlalchemy import text @@ -28,6 +32,56 @@ from .layout_routes import DEFAULTS router = APIRouter(prefix="/shop", tags=["shop"]) +async def _tracking_head(db) -> str: + """Analytics/marketing snippets for the storefront — Meta Pixel, Umami, GA4 (all opt-in via settings).""" + rows = await db.execute(text( + "SELECT setting_key, setting_value FROM sales_setting " + "WHERE setting_key IN ('meta_pixel_id','umami_website_id','umami_src','ga4_id') AND is_active")) + s = {r["setting_key"]: (r["setting_value"] or "").strip() for r in rows.mappings()} + out = [] + if s.get("meta_pixel_id"): + pid = s["meta_pixel_id"] + out.append("" + f'') + if s.get("umami_website_id"): + src = s.get("umami_src") or "https://cloud.umami.is/script.js" + out.append(f'') + if s.get("ga4_id"): + g = s["ga4_id"] + out.append(f'' + "") + return "".join(out) + + +@router.get("/page/{slug}", response_class=HTMLResponse) +async def shop_page(slug: str, db=Depends(get_db)): + """Server-rendered content page (About/Shipping/Returns/…) — SEO-friendly HTML, the WP Pages public view.""" + p = (await db.execute(text( + "SELECT title, body, seo_title, seo_description FROM page WHERE slug = :s AND published"), + {"s": slug})).mappings().first() + if not p: + raise HTTPException(404, "page not found") + name = (await db.execute(text("SELECT setting_value FROM sales_setting WHERE setting_key='store_name'"))).scalar() or "Store" + e = html.escape + title = p["seo_title"] or f'{p["title"]} · {name}' + desc = p["seo_description"] or "" + trk = await _tracking_head(db) + page = (f'' + f'' + f'{e(title)}' + f'' + f'' + f'{trk}

← {e(name)}

{e(p["title"])}

{p["body"]}') + return HTMLResponse(page) + + @router.get("/config") async def shop_config(db=Depends(get_db)): row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first() @@ -340,6 +394,84 @@ async def shop_shipping_quote(units: int | None = None, weight_g: int | None = N return {"weight_g": weight_g, "parcels": parcels, "options": opts} +class CheckoutItem(BaseModel): + sku: str + qty: int = 1 + + +class CheckoutIn(BaseModel): + items: list[CheckoutItem] + provider: str = "mock" + token: str # tokenised card from the frontend SDK (never raw card data) + email: str | None = None + name: str | None = None + pickup: bool = False # skip postage + + +@router.post("/checkout") +async def checkout(body: CheckoutIn, db=Depends(get_db)): + """Online checkout — the WooCommerce replacement. SERVER-SIDE priced (never trusts the client), GST-inclusive + (AU), cheapest AusPost postage, charged via ANY configured processor, writes the order + marks stock sold.""" + from . import payments + if not body.items: + raise HTTPException(400, "empty cart") + if body.provider not in await payments.enabled_providers(db): + raise HTTPException(403, f"payment provider '{body.provider}' is not available") + rows = {r["sku"]: dict(r) for r in (await db.execute(text(""" + SELECT i.sku, coalesce(i.title, dc.title) AS title, i.price::float AS price, i.in_stock, + i.weight_g::float AS weight_g, i.release_id + FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id + WHERE i.sku = ANY(:skus) AND i.store_id = 1"""), {"skus": [i.sku for i in body.items]})).mappings()} + lines, subtotal, weight = [], 0.0, 0.0 + for it in body.items: + r = rows.get(it.sku) + if not r: + raise HTTPException(400, f"unknown item {it.sku}") + if not r["in_stock"]: + raise HTTPException(409, f"{r.get('title') or it.sku} is already sold") + qty, price = max(1, it.qty), float(r["price"] or 0) + subtotal += price * qty + weight += (r["weight_g"] or 280) * qty + lines.append({"sku": it.sku, "title": r.get("title"), "release_id": r.get("release_id"), "qty": qty, "price": price}) + shipping = 0.0 + if not body.pickup: + wg = int(weight) + 50 * len(lines) # +packaging + parcels = max(1, -(-wg // 5000)); per = -(-wg // parcels) + cheapest = ((await db.execute(text( + "SELECT min(price)::float AS p FROM post_flat_rate WHERE :w BETWEEN weight_min_g AND weight_max_g"), + {"w": per})).mappings().first() or {}).get("p") + shipping = round((cheapest or 0) * parcels, 2) + total = round(subtotal + shipping, 2) + gst = round(total / 11, 2) # AU GST-inclusive component (for the order record) + ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S") + pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref) + if not pay.get("ok"): + return {"ok": False, "stage": "payment", "error": pay.get("error")} + sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper() + sale_id = (await db.execute(text(""" + INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method, + payment_status, amount_paid, notes, sale_date, created_at) + VALUES (:sn, :sub, 0, :tax, :total, 'publish', :pm, 'paid', :total, :notes, now(), now()) RETURNING id"""), + {"sn": sn, "sub": round(subtotal, 2), "tax": gst, "total": total, "pm": pay["provider"], + "notes": f"online · pay {pay.get('payment_id')} · ship ${shipping}" + (f" · {body.email}" if body.email else "")})).first()[0] + for li in lines: + await db.execute(text("""INSERT INTO sale_items (sale_id, sku, release_id, item_name, qty, unit_price, line_total) + VALUES (:s,:sku,:rid,:nm,:q,:up,:lt)"""), + {"s": sale_id, "sku": li["sku"], "rid": li["release_id"], "nm": li["title"], + "q": li["qty"], "up": li["price"], "lt": round(li["price"] * li["qty"], 2)}) + await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now() WHERE sku=:sku AND store_id=1"), + {"sku": li["sku"]}) + await db.commit() + try: # anti-oversell: delist these from Discogs now they've sold online (best-effort) + from . import discogs_mp + await discogs_mp.delist_skus(db, [li["sku"] for li in lines]) + except Exception: + pass + return {"ok": True, "order": sn, "sale_id": sale_id, "subtotal": round(subtotal, 2), + "shipping": shipping, "gst": gst, "total": total, + "payment": {"provider": pay["provider"], "id": pay.get("payment_id")}} + + class WooOrderIn(BaseModel): order_number: str email: str | None = None diff --git a/customer_woo_sync.py b/customer_woo_sync.py index 6d3d639..7d7deae 100644 --- a/customer_woo_sync.py +++ b/customer_woo_sync.py @@ -35,7 +35,7 @@ FROM _woo w WHERE c.wp_user_id = w.wp_user_id; -- 3. brand-new online customer (no wp_user_id match AND no email match) INSERT INTO customer (wp_user_id, first_name, last_name, email, phone, address, is_guest) -SELECT w.wp_user_id, w.first_name, w.last_name, w.email, w.phone, w.address, false +SELECT w.wp_user_id, coalesce(w.first_name,''), coalesce(w.last_name,''), w.email, w.phone, w.address, false FROM _woo w WHERE NOT EXISTS (SELECT 1 FROM customer c WHERE c.wp_user_id = w.wp_user_id) AND NOT EXISTS (SELECT 1 FROM customer c WHERE w.email <> '' AND lower(c.email) = lower(w.email)); diff --git a/disc_sync.py b/disc_sync.py index 82cc3cb..9414fab 100644 --- a/disc_sync.py +++ b/disc_sync.py @@ -120,9 +120,10 @@ def main(): rs.setdefault(r["release_id"], []).append(r["style_id"]) # releases + # live dropped the stored `search_text`; compute it (title + artist) for RecordGod's fuzzy index c.execute(f"""SELECT id, title, artists_sort, released, country, year, notes, format_quantity, description, apple_id, estimated_weight, master_id, thumb, rating_count, - rating_average, search_text FROM {P}release""") + rating_average, LOWER(CONCAT_WS(' ', title, artists_sort)) AS search_text FROM {P}release""") cols = ["id", "title", "artists_sort", "released", "country", "year", "notes", "format_quantity", "description", "apple_id", "estimated_weight", "master_id", "thumb", "rating_count", "rating_average", "search_text", "genre_ids", "style_ids"] diff --git a/site/search.html b/site/search.html index c83f2f8..66ebc90 100644 --- a/site/search.html +++ b/site/search.html @@ -94,6 +94,7 @@ + @@ -161,6 +162,22 @@ function directionYaw(d){ d=(d||'').toLowerCase(); if(d==='right'||d==='east') return -Math.PI/2; return 0; } const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'}; +// Match the 3D store EXACTLY: stored pos_x/pos_z is a rotated CORNER-anchor, not the centre (webstore l.319-326). +function rackGeom(r){ // -> scene-coord rack CENTRE (cx,cz) + effective yaw (rad), so the 2D map == the 3D store + const W=+(ST.space&&ST.space.room_width)||12, D=+(ST.space&&ST.space.room_depth)||12; + const hx=(r.w||0.5)/2, hz=(r.d||0.5)/2, rpx=+r.x||0, rpz=+r.z||0, a=(r.attach_wall||'').toLowerCase(); + const rot=(+r.rot||0)*Math.PI/180, base=directionYaw(r.direction)+rot; let yaw=base, cx, cz; + if(a==='north'){ yaw=rot; cz=-D/2+hz+Math.max(0,rpz); cx=-W/2+hx+rpx; } + else if(a==='south'){ yaw=Math.PI+rot; cz=D/2-hz-Math.max(0,rpz); cx=-W/2+hx+rpx; } + else if(a==='east'){ yaw=-Math.PI/2+rot; cx=W/2-hz-Math.max(0,rpz); cz=-D/2+hx+rpx; } + else if(a==='west'){ yaw=Math.PI/2+rot; cx=-W/2+hz+Math.max(0,rpz); cz=-D/2+hx+rpx; } + else { cx=-W/2+rpx+hx*Math.cos(base)-hz*Math.sin(base); cz=-D/2+rpz+hx*Math.sin(base)+hz*Math.cos(base); } + return {cx, cz, yaw}; +} +function geomToPos(r, cx, cz, yaw){ // FREE inverse: desired scene CENTRE -> corner-anchored pos_x/pos_z + const W=+(ST.space&&ST.space.room_width)||12, D=+(ST.space&&ST.space.room_depth)||12, hx=(r.w||0.5)/2, hz=(r.d||0.5)/2; + return { pos_x: cx+W/2-(hx*Math.cos(yaw)-hz*Math.sin(yaw)), pos_z: cz+D/2-(hx*Math.sin(yaw)+hz*Math.cos(yaw)) }; +} let ST={tab:'scanner', view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[], selCrate:null, hit:[], storeTf:null, itemShown:false, @@ -196,8 +213,8 @@ function logout(){ localStorage.removeItem('rg_token'); location.reload(); } async function loadSpaces(){ - const d=await get('/nav/spaces'); - $('#spaceSel').innerHTML=(d.spaces||[]).map(s=>``).join(''); + const d=await get('/nav/spaces'); ST.spaces=d.spaces||[]; + $('#spaceSel').innerHTML=ST.spaces.map(s=>``).join(''); $('#spaceSel').onchange=()=>toStore(); } @@ -237,12 +254,12 @@ function rotateView(){ ST.viewRot=((ST.viewRot||0)+90)%360; redraw(); } // ───────────── STORE VIEW ───────────── async function toStore(){ ST.view='store'; ST.rack=null; ST.selCrate=null; ST.crateInfo=null; renderCrateInfo(); - $('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none'; + $('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none'; $('#rackRoomSel').style.display='none'; $('#crumb').textContent='Store map'; const sid=$('#spaceSel').value; const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):'')); ST.space=d.space; ST.racks=d.racks||[]; - $('#legend').innerHTML='rack — click to open'+(ST.tab==='reorganize'?' · drag to move':'')+''; + $('#legend').innerHTML='rack — click to open'+(ST.tab==='reorganize'?' · drag = move · shift-drag = rotate':'')+''; drawStore(); setViewBtns(); } function drawStore(){ @@ -250,22 +267,25 @@ function drawStore(){ g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H); ST.hit=[]; if(!ST.racks.length){ g.fillStyle='#888'; g.font='14px system-ui'; g.textAlign='center'; g.fillText('No racks in this space',W/2,H/2); return; } + // each rack's TRUE scene centre + yaw (== the 3D store); _edit* overrides win during a drag/rotate gesture + ST.racks.forEach(r=>{ const gm=rackGeom(r); r._cx=r._editCx??gm.cx; r._cz=r._editCz??gm.cz; r._yaw=r._editYaw??gm.yaw; }); let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9; - ST.racks.forEach(r=>{ const hw=(r.w||0.5)/2, hd=(r.d||0.5)/2; - minX=Math.min(minX,r.x-hw); maxX=Math.max(maxX,r.x+hw); minZ=Math.min(minZ,r.z-hd); maxZ=Math.max(maxZ,r.z+hd); }); + ST.racks.forEach(r=>{ const hd=Math.hypot((r.w||0.5)/2,(r.d||0.5)/2); + minX=Math.min(minX,r._cx-hd); maxX=Math.max(maxX,r._cx+hd); minZ=Math.min(minZ,r._cz-hd); maxZ=Math.max(maxZ,r._cz+hd); }); const pad=34, sc=Math.min((W-2*pad)/((maxX-minX)||1),(H-2*pad)/((maxZ-minZ)||1)); ST.storeTf={minX,minZ,sc,pad}; const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc; const q=(((ST.viewRot||0)/90)%4+4)%4; ST.racks.forEach(r=>{ - const yaw=directionYaw(r.direction)+(r.rot||0)*Math.PI/180 + q*Math.PI/2; + const yaw=r._yaw + q*Math.PI/2; const hw=(r.w||0.5)/2*sc, hd=(r.d||0.5)/2*sc; - let cx=X(r.x), cy=Y(r.z); + let cx=X(r._cx), cy=Y(r._cz); if(q){ const a=q*Math.PI/2, co=Math.cos(a), si=Math.sin(a), dx=cx-W/2, dy=cy-H/2; cx=W/2+dx*co-dy*si; cy=H/2+dx*si+dy*co; } const cos=Math.cos(yaw), sin=Math.sin(yaw); const corners=[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([px,pz])=>[cx+px*cos-pz*sin, cy+px*sin+pz*cos]); g.beginPath(); g.moveTo(corners[0][0],corners[0][1]); corners.slice(1).forEach(c=>g.lineTo(c[0],c[1])); g.closePath(); g.fillStyle=r.items? '#8bc34a':'#cfe3b0'; g.fill(); g.strokeStyle='#558b2f'; g.lineWidth=1.5; g.stroke(); + g.beginPath(); g.moveTo(cx,cy); g.lineTo(cx+sin*hd, cy-cos*hd); g.strokeStyle='#33691e'; g.lineWidth=2; g.stroke(); // facing tick g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='middle'; const lbl=(r.name||('Rack '+r.id)); g.fillText(lbl.length>15?lbl.slice(0,14)+'…':lbl, cx, cy-(r.items?5:0)); if(r.items){ g.fillStyle='#3a3a3a'; g.font='9px system-ui'; g.fillText(r.items+' items', cx, cy+7); } @@ -282,6 +302,8 @@ async function openRack(id, focusCrate){ : (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1); ST.selCrate=focusCrate||null; $('#rackEditBtn').style.display=''; + const rrs=$('#rackRoomSel'); rrs.style.display=''; + rrs.innerHTML=(ST.spaces||[]).map(s=>``).join(''); $('#crumb').innerHTML='Store / '+esc(ST.rack.name||('Rack '+ST.rack.id))+''; drawRack(); setViewBtns(); if(ST.tab==='reorganize') renderPick(); @@ -356,6 +378,13 @@ async function rackEdit(){ const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return; await post('/nav/rack/'+ST.rack.id,{name:n}); openRack(ST.rack.id, ST.selCrate); } +async function moveRackRoom(){ + const to=+$('#rackRoomSel').value; if(!ST.rack || to===ST.rack.space_id) return; + const nm=(ST.spaces.find(s=>s.id===to)||{}).name||('Space '+to); + if(!confirm(`Move "${ST.rack.name||('Rack '+ST.rack.id)}" and its crates to ${nm}?`)){ $('#rackRoomSel').value=ST.rack.space_id; return; } + await post('/nav/rack/'+ST.rack.id,{space_id:to}); + $('#spaceSel').value=to; toStore(); // jump to the destination room, moved rack in place +} // ───────────── crate contents (centre column) ───────────── async function loadCrate(id){ @@ -726,20 +755,30 @@ function hitRack(mx,my){ let best=null,bd=1e9; for(const h of ST.hit){ if(h.type function hitCrate(mx,my){ for(const h of ST.hit){ if(h.type==='crate'&&mx>=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h) return h; } return null; } let down=null; $('#cv').addEventListener('mousedown',e=>{ - const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null}; - if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my); if(h) down.dragRack=h.id; } + const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null,rot:false}; + if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my); + if(h){ const r=ST.racks.find(x=>x.id===h.id); down.dragRack=h.id; down.rot=e.shiftKey; // drag = move · shift-drag = rotate + down.cx=h.cx; down.cy=h.cy; down.startYaw=r._yaw||0; down.startAng=Math.atan2(my-h.cy,mx-h.cx); } } }); $('#cv').addEventListener('mousemove',e=>{ if(!down||!down.dragRack) return; const {mx,my}=cvXY(e); if(Math.hypot(mx-down.mx,my-down.my)>5) down.moved=true; - if(down.moved){ const tf=ST.storeTf, r=ST.racks.find(x=>x.id===down.dragRack), [wmx,wmy]=unrotPt(mx,my); - r.x=tf.minX+(wmx-tf.pad)/tf.sc; r.z=tf.minZ+(wmy-tf.pad)/tf.sc; drawStore(); } + if(!down.moved) return; + const r=ST.racks.find(x=>x.id===down.dragRack), tf=ST.storeTf; + if(down.rot){ r._editYaw=down.startYaw+(Math.atan2(my-down.cy,mx-down.cx)-down.startAng); } // rotate about its centre + else { const [wmx,wmy]=unrotPt(mx,my); r._editCx=tf.minX+(wmx-tf.pad)/tf.sc; r._editCz=tf.minZ+(wmy-tf.pad)/tf.sc; } + drawStore(); }); window.addEventListener('mouseup',async e=>{ if(!down) return; const d=down; down=null; if(d.dragRack && d.moved){ const r=ST.racks.find(x=>x.id===d.dragRack); - await post('/nav/rack/'+d.dragRack,{pos_x:+r.x.toFixed(3),pos_z:+r.z.toFixed(3)}); return; } + const cx=r._editCx??r._cx, cz=r._editCz??r._cz, yaw=r._editYaw??r._yaw; // final centre + facing + const p=geomToPos(r,cx,cz,yaw), deg=+(((yaw*180/Math.PI)%360+360)%360).toFixed(1); + r.x=p.pos_x; r.z=p.pos_z; r.rot=deg; r.direction='forward'; r.attach_wall=''; // moved rack becomes FREE, facing → rotation_y + delete r._editCx; delete r._editCz; delete r._editYaw; drawStore(); + await post('/nav/rack/'+d.dragRack,{pos_x:+p.pos_x.toFixed(3),pos_z:+p.pos_z.toFixed(3),rotation_y:deg,direction:'forward',attach_wall:''}); + return; } if(d.moved) return; // a non-rack drag — ignore // a real click → dispatch by view + tab const {mx,my}=cvXY(e); diff --git a/test_dealgod_supply.py b/test_dealgod_supply.py new file mode 100644 index 0000000..26630de --- /dev/null +++ b/test_dealgod_supply.py @@ -0,0 +1,68 @@ +"""Self-check for the concurrent dealgod.supply() batcher. No network — fake httpx client. +Run: .venv/bin/python test_dealgod_supply.py""" +import asyncio +import app.dealgod as dg + +SEEN = [] # batches the fake client received + + +class _Resp: + status_code = 200 + + def __init__(self, ids): + self.ids = ids + + def json(self): + return {"results": {str(i): {"n": 1} for i in self.ids}} + + +class _FakeClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, json=None): + ids = json["ids"] + SEEN.append(list(ids)) + if -1 in ids: # poison sentinel → simulate a failing batch + raise RuntimeError("simulated batch failure") + return _Resp(ids) + + +async def _fake_key(db): + return "fake-key" + + +def test_supply(): + orig_client, orig_key = dg.httpx.AsyncClient, dg._key + dg.httpx.AsyncClient, dg._key = _FakeClient, _fake_key + try: + # 450 ids → 3 batches (200/200/50), all results merged + SEEN.clear() + res = asyncio.run(dg.supply(None, list(range(450)))) + assert len(SEEN) == 3, SEEN + assert sorted(len(b) for b in SEEN) == [50, 200, 200] + assert len(res) == 450 and res["0"] == {"n": 1} and res["449"] == {"n": 1} + + # empty ids → {} with zero calls + SEEN.clear() + assert asyncio.run(dg.supply(None, [])) == {} and SEEN == [] + + # ONE failing batch among several is dropped; the others still merge (isolation) + SEEN.clear() + ids2 = list(range(200)) + [-1] + list(range(201, 250)) # batch 2 holds the poison + res2 = asyncio.run(dg.supply(None, ids2)) + assert len(SEEN) == 2 + assert len(res2) == 200 and "0" in res2 and "201" not in res2 + print("ok — concurrent batching, merge, empty-guard, degrade-isolation all pass") + finally: + dg.httpx.AsyncClient, dg._key = orig_client, orig_key + + +if __name__ == "__main__": + test_supply() diff --git a/test_startup_ddl.py b/test_startup_ddl.py new file mode 100644 index 0000000..c3dc99a --- /dev/null +++ b/test_startup_ddl.py @@ -0,0 +1,54 @@ +"""Self-check for the per-statement startup DDL guard (main._ensure_tables). +Proves the real guarantee: ONE failing statement is isolated — every other statement is still +attempted and the app boots. No live DB (fake engine). Run: .venv/bin/python test_startup_ddl.py""" +import asyncio +import app.main as m + + +class _FakeConn: + def __init__(self, boom): + self.boom, self.seen = boom, [] + + async def execute(self, stmt): + s = str(stmt) + self.seen.append(s) + if self.boom in s: + raise RuntimeError("simulated DDL failure") + + +class _FakeBegin: + def __init__(self, conn): + self.conn = conn + + async def __aenter__(self): + return self.conn + + async def __aexit__(self, *a): + return False # don't swallow — the loop's own try/except must handle it + + +class _FakeEngine: + def __init__(self, conn): + self.conn = conn + + def begin(self): + return _FakeBegin(self.conn) + + +def test_isolation(): + boom = "CREATE EXTENSION IF NOT EXISTS pg_trgm" # a real statement in the list + conn = _FakeConn(boom) + orig = m.engine + m.engine = _FakeEngine(conn) + try: + asyncio.run(m._ensure_tables()) # must NOT raise + finally: + m.engine = orig + assert any(boom in s for s in conn.seen), "the boom statement was never reached" + assert len(conn.seen) == len(m._STARTUP_DDL), \ + f"only {len(conn.seen)}/{len(m._STARTUP_DDL)} attempted — a failure aborted the batch" + print(f"ok — all {len(conn.seen)} statements attempted; the failing one was isolated") + + +if __name__ == "__main__": + test_isolation()