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:
parent
d46025e887
commit
db1788bfe5
@ -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
160
app/dealgod.py
Normal 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
214
app/discogs_mp.py
Normal 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()
|
||||||
126
app/main.py
126
app/main.py
@ -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():
|
||||||
|
|||||||
@ -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
127
app/packs.py
Normal 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
141
app/payments.py
Normal 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()
|
||||||
@ -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}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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
68
test_dealgod_supply.py
Normal 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
54
test_startup_ddl.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user