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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-02 23:07:28 +10:00
parent d46025e887
commit db1788bfe5
14 changed files with 1447 additions and 58 deletions

View File

@ -40,7 +40,9 @@ async def me(ident=Depends(require_token), db=Depends(get_db)):
if ident.get("staff_id"): if ident.get("staff_id"):
await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": ident["staff_id"]}) await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": ident["staff_id"]})
await db.commit() 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 ── # ── 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): async def _dealgod_supply(db, release_ids):
"""Batch DealGod /api/supply → {release_id(str): {store_count, discogs_seller_count, …}}.""" """Batch DealGod /api/supply → {release_id(str): {store_count, discogs_seller_count, …}}.
key = await vault.get_secret(db, "dealgod_api_key") Now delegates to the shared DealGod client (app/dealgod.py) the single seam to the brain."""
if not key or not release_ids: from . import dealgod
return {} return await dealgod.supply(db, release_ids)
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
class WishItem(BaseModel): class WishItem(BaseModel):
@ -402,6 +393,139 @@ class InvItemIn(BaseModel):
in_stock: bool = True 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): class InvEditIn(BaseModel):
price: float | None = None price: float | None = None
cost_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} 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") @router.get("/orders")
async def orders(ident=Depends(require_token), db=Depends(get_db)): async def orders(ident=Depends(require_token), db=Depends(get_db)):
base = await vault.get_secret(db, "woo_base_url") base = await vault.get_secret(db, "woo_base_url")

160
app/dealgod.py Normal file
View File

@ -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 {<id str>: {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()

214
app/discogs_mp.py Normal file
View File

@ -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()

View File

@ -1,6 +1,9 @@
import logging
import os import os
import pathlib import pathlib
log = logging.getLogger("recordgod")
# Load .env FIRST so auth/db/vault see their keys at import time. # Load .env FIRST so auth/db/vault see their keys at import time.
_ENV = pathlib.Path(__file__).resolve().parent.parent / ".env" _ENV = pathlib.Path(__file__).resolve().parent.parent / ".env"
if _ENV.exists(): if _ENV.exists():
@ -10,9 +13,9 @@ if _ENV.exists():
_k, _v = _ln.split("=", 1) _k, _v = _ln.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip()) 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.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 . import __version__ # noqa: E402
from .wowplatter_v1 import router as wowplatter_v1_router # 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_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 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 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) # 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 " "CREATE INDEX IF NOT EXISTS disc_rel_id_barcode_norm ON disc_release_identifier "
"(regexp_replace(value,'[^0-9]','','g')) WHERE type='Barcode'", "(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 text",
"ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_radius numeric", "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_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") @app.on_event("startup")
async def _ensure_tables(): async def _ensure_tables():
# lazy migrations on deploy (store_config + POS columns/sequences) # Lazy idempotent migrations on deploy. Each statement runs in its OWN transaction so a
async with engine.begin() as conn: # single failure (e.g. an ALTER on a table a prior step hasn't created yet) is isolated and
for _stmt in _STARTUP_DDL: # logged — it can't abort the whole batch and boot the app with NO schema. All statements are
await conn.execute(_sqltext(_stmt)) # 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") @app.get("/healthz")
@ -139,6 +168,50 @@ async def healthz():
return {"ok": True, "service": "recordgod", "version": __version__} 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"<url><loc>{base}{p}</loc></url>" for p in paths)
xml = ('<?xml version="1.0" encoding="UTF-8"?>'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + locs + "</urlset>")
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 _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…) # entity browse pages — records.html reads the kind+value from the path (/artist/9, /genre/House…)
for _ent in ("artist", "label", "genre", "style"): for _ent in ("artist", "label", "genre", "style"):
app.add_api_route(f"/{_ent}/{{val}}", _page("records.html"), methods=["GET"], include_in_schema=False) 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 # "/" honours the homepage setting (Store Settings → Homepage): storefront → /shop, 3d → /store,
app.add_api_route("/", _page("index.html"), methods=["GET"], include_in_schema=False) # 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. # Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.
if (_ROOT / "webstore").is_dir(): if (_ROOT / "webstore").is_dir():

View File

@ -36,7 +36,7 @@ async def store_layout(space_id: int | None = None, ident=Depends(require_token)
"""), {"s": space_id})).mappings().first() """), {"s": space_id})).mappings().first()
racks = [dict(r) for r in (await db.execute(text(""" 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, 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.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, 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, (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 pos_z: float | None = None
direction: str | None = None # forward | back | left | right direction: str | None = None # forward | back | left | right
rotation_y: float | None = None # fine rotation (degrees) 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}") @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} return {"ok": True, "unchanged": True}
sets = ", ".join(f"{k} = :{k}" for k in fields) + ", updated_at = now()" 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}) 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() await db.commit()
if not r.rowcount: if not r.rowcount:
raise HTTPException(404, "rack not found") raise HTTPException(404, "rack not found")

127
app/packs.py Normal file
View File

@ -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()

141
app/payments.py Normal file
View File

@ -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()

View File

@ -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 WHERE sku = ANY(:skus) AND store_id = 1
"""), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]}) """), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]})
await db.commit() 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, 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} "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]} 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): async def _settings(db):
rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active")) 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()} s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()}
return {"currency_symbol": s.get("currency_symbol", "$"), return {k: s.get(_SETTING_ALIASES.get(k, k), s.get(k, d)) for k, d in _SETTING_DEFAULTS.items()}
"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", "")}
@router.get("/settings") @router.get("/settings")
@ -297,17 +310,14 @@ async def get_settings(ident=Depends(require_token), db=Depends(get_db)):
@router.post("/settings") @router.post("/settings")
async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)): 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"), for k, v in body.items():
"default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type"), if k not in _SETTING_DEFAULTS or v is None:
"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:
continue continue
await db.execute(text(""" await db.execute(text("""
INSERT INTO sales_setting (setting_key, setting_value, updated_at) VALUES (:k, :v, now()) INSERT INTO sales_setting (setting_key, setting_value, is_active, updated_at)
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now() VALUES (:k, :v, true, now())
"""), {"k": k, "v": str(v)}) 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() await db.commit()
return {"ok": True} return {"ok": True}

View File

@ -1,9 +1,13 @@
import asyncio import asyncio
import html
import json import json
import re import re
import secrets
from datetime import datetime
import httpx import httpx
from fastapi import APIRouter, Depends, Query, HTTPException, Header from fastapi import APIRouter, Depends, Query, HTTPException, Header
from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
@ -28,6 +32,56 @@ from .layout_routes import DEFAULTS
router = APIRouter(prefix="/shop", tags=["shop"]) router = APIRouter(prefix="/shop", tags=["shop"])
async def _tracking_head(db) -> str:
"""Analytics/marketing snippets for the storefront <head> — 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("<script>!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?"
"n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;"
"n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;"
"s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script',"
f"'https://connect.facebook.net/en_US/fbevents.js');fbq('init','{pid}');fbq('track','PageView');</script>"
f'<noscript><img height="1" width="1" style="display:none" '
f'src="https://www.facebook.com/tr?id={pid}&ev=PageView&noscript=1"/></noscript>')
if s.get("umami_website_id"):
src = s.get("umami_src") or "https://cloud.umami.is/script.js"
out.append(f'<script defer src="{src}" data-website-id="{s["umami_website_id"]}"></script>')
if s.get("ga4_id"):
g = s["ga4_id"]
out.append(f'<script async src="https://www.googletagmanager.com/gtag/js?id={g}"></script>'
"<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}"
f"gtag('js',new Date());gtag('config','{g}');</script>")
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'<!doctype html><html lang="en"><head><meta charset="utf-8">'
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
f'<title>{e(title)}</title><meta name="description" content="{e(desc)}">'
f'<meta property="og:title" content="{e(title)}"><meta property="og:description" content="{e(desc)}">'
f'<style>body{{font:16px/1.6 system-ui,-apple-system,sans-serif;max-width:720px;margin:40px auto;'
f'padding:0 20px;color:#1a1a1a}}h1{{font-size:28px}}a{{color:#c0267e}}img{{max-width:100%}}</style>'
f'{trk}</head><body><p><a href="/">← {e(name)}</a></p><h1>{e(p["title"])}</h1>{p["body"]}</body></html>')
return HTMLResponse(page)
@router.get("/config") @router.get("/config")
async def shop_config(db=Depends(get_db)): async def shop_config(db=Depends(get_db)):
row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first() 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} 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): class WooOrderIn(BaseModel):
order_number: str order_number: str
email: str | None = None email: str | None = None

View File

@ -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) -- 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) 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 FROM _woo w
WHERE NOT EXISTS (SELECT 1 FROM customer c WHERE c.wp_user_id = w.wp_user_id) 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)); AND NOT EXISTS (SELECT 1 FROM customer c WHERE w.email <> '' AND lower(c.email) = lower(w.email));

View File

@ -120,9 +120,10 @@ def main():
rs.setdefault(r["release_id"], []).append(r["style_id"]) rs.setdefault(r["release_id"], []).append(r["style_id"])
# releases # 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, c.execute(f"""SELECT id, title, artists_sort, released, country, year, notes, format_quantity,
description, apple_id, estimated_weight, master_id, thumb, rating_count, 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", cols = ["id", "title", "artists_sort", "released", "country", "year", "notes", "format_quantity",
"description", "apple_id", "estimated_weight", "master_id", "thumb", "rating_count", "description", "apple_id", "estimated_weight", "master_id", "thumb", "rating_count",
"rating_average", "search_text", "genre_ids", "style_ids"] "rating_average", "search_text", "genre_ids", "style_ids"]

View File

@ -94,6 +94,7 @@
<button class="ghost" id="lvlNext" onclick="stepLevel(1)"></button> <button class="ghost" id="lvlNext" onclick="stepLevel(1)"></button>
</span> </span>
<button class="ghost" id="rackEditBtn" style="display:none" onclick="rackEdit()">✏️ rack</button> <button class="ghost" id="rackEditBtn" style="display:none" onclick="rackEdit()">✏️ rack</button>
<select id="rackRoomSel" style="display:none;padding:7px 9px" title="move this rack (and its crates) to another room" onchange="moveRackRoom()"></select>
<button class="ghost" id="rotBtn" onclick="rotateView()" title="rotate the diagram 90°">🔄 rotate</button> <button class="ghost" id="rotBtn" onclick="rotateView()" title="rotate the diagram 90°">🔄 rotate</button>
<span id="selModeHint" class="selmode" style="display:none">● Select Mode — click crates in order</span> <span id="selModeHint" class="selmode" style="display:none">● Select Mode — click crates in order</span>
</div> </div>
@ -161,6 +162,22 @@ function directionYaw(d){ d=(d||'').toLowerCase();
if(d==='right'||d==='east') return -Math.PI/2; if(d==='right'||d==='east') return -Math.PI/2;
return 0; } return 0; }
const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'}; 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:[], let ST={tab:'scanner', view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[],
selCrate:null, hit:[], storeTf:null, itemShown:false, selCrate:null, hit:[], storeTf:null, itemShown:false,
@ -196,8 +213,8 @@ function logout(){
localStorage.removeItem('rg_token'); location.reload(); localStorage.removeItem('rg_token'); location.reload();
} }
async function loadSpaces(){ async function loadSpaces(){
const d=await get('/nav/spaces'); const d=await get('/nav/spaces'); ST.spaces=d.spaces||[];
$('#spaceSel').innerHTML=(d.spaces||[]).map(s=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).join(''); $('#spaceSel').innerHTML=ST.spaces.map(s=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).join('');
$('#spaceSel').onchange=()=>toStore(); $('#spaceSel').onchange=()=>toStore();
} }
@ -237,12 +254,12 @@ function rotateView(){ ST.viewRot=((ST.viewRot||0)+90)%360; redraw(); }
// ───────────── STORE VIEW ───────────── // ───────────── STORE VIEW ─────────────
async function toStore(){ async function toStore(){
ST.view='store'; ST.rack=null; ST.selCrate=null; ST.crateInfo=null; renderCrateInfo(); 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'; $('#crumb').textContent='Store map';
const sid=$('#spaceSel').value; const sid=$('#spaceSel').value;
const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):'')); const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):''));
ST.space=d.space; ST.racks=d.racks||[]; ST.space=d.space; ST.racks=d.racks||[];
$('#legend').innerHTML='<span><i class="sw" style="background:#8bc34a"></i>rack — click to open'+(ST.tab==='reorganize'?' · <b class="pink">drag to move</b>':'')+'</span>'; $('#legend').innerHTML='<span><i class="sw" style="background:#8bc34a"></i>rack — click to open'+(ST.tab==='reorganize'?' · <b class="pink">drag = move · shift-drag = rotate</b>':'')+'</span>';
drawStore(); setViewBtns(); drawStore(); setViewBtns();
} }
function drawStore(){ function drawStore(){
@ -250,22 +267,25 @@ function drawStore(){
g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H); g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H);
ST.hit=[]; 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; } 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; 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; ST.racks.forEach(r=>{ const hd=Math.hypot((r.w||0.5)/2,(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); }); 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)); const pad=34, sc=Math.min((W-2*pad)/((maxX-minX)||1),(H-2*pad)/((maxZ-minZ)||1));
ST.storeTf={minX,minZ,sc,pad}; ST.storeTf={minX,minZ,sc,pad};
const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc; const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc;
const q=(((ST.viewRot||0)/90)%4+4)%4; const q=(((ST.viewRot||0)/90)%4+4)%4;
ST.racks.forEach(r=>{ 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; 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; } 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 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]); 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.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.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'; 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)); 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); } 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); : (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1);
ST.selCrate=focusCrate||null; ST.selCrate=focusCrate||null;
$('#rackEditBtn').style.display=''; $('#rackEditBtn').style.display='';
const rrs=$('#rackRoomSel'); rrs.style.display='';
rrs.innerHTML=(ST.spaces||[]).map(s=>`<option value="${s.id}" ${s.id===ST.rack.space_id?'selected':''}>📍 ${esc(s.name||('Space '+s.id))}</option>`).join('');
$('#crumb').innerHTML='Store / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>'; $('#crumb').innerHTML='Store / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>';
drawRack(); setViewBtns(); drawRack(); setViewBtns();
if(ST.tab==='reorganize') renderPick(); if(ST.tab==='reorganize') renderPick();
@ -356,6 +378,13 @@ async function rackEdit(){
const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return; 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); 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) ───────────── // ───────────── crate contents (centre column) ─────────────
async function loadCrate(id){ 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; } 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; let down=null;
$('#cv').addEventListener('mousedown',e=>{ $('#cv').addEventListener('mousedown',e=>{
const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null}; 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) down.dragRack=h.id; } 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=>{ $('#cv').addEventListener('mousemove',e=>{
if(!down||!down.dragRack) return; if(!down||!down.dragRack) return;
const {mx,my}=cvXY(e); const {mx,my}=cvXY(e);
if(Math.hypot(mx-down.mx,my-down.my)>5) down.moved=true; 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); if(!down.moved) return;
r.x=tf.minX+(wmx-tf.pad)/tf.sc; r.z=tf.minZ+(wmy-tf.pad)/tf.sc; drawStore(); } 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=>{ window.addEventListener('mouseup',async e=>{
if(!down) return; const d=down; down=null; if(!down) return; const d=down; down=null;
if(d.dragRack && d.moved){ const r=ST.racks.find(x=>x.id===d.dragRack); 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 if(d.moved) return; // a non-rack drag — ignore
// a real click → dispatch by view + tab // a real click → dispatch by view + tab
const {mx,my}=cvXY(e); const {mx,my}=cvXY(e);

68
test_dealgod_supply.py Normal file
View File

@ -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()

54
test_startup_ddl.py Normal file
View File

@ -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()