Compare commits
3 Commits
916b26102c
...
db1788bfe5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db1788bfe5 | ||
|
|
d46025e887 | ||
|
|
d0c91faf79 |
@ -40,7 +40,9 @@ async def me(ident=Depends(require_token), db=Depends(get_db)):
|
||||
if ident.get("staff_id"):
|
||||
await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": ident["staff_id"]})
|
||||
await db.commit()
|
||||
return {"name": ident.get("name"), "role": ident.get("role"), "staff_id": ident.get("staff_id")}
|
||||
from . import packs
|
||||
return {"name": ident.get("name"), "role": ident.get("role"), "staff_id": ident.get("staff_id"),
|
||||
**packs.info()}
|
||||
|
||||
|
||||
# ── Staff accounts (ADMIN ONLY) — operators get a token + role; tokens shown once on create/reset ──
|
||||
@ -260,21 +262,10 @@ _COLOUR_RE = re.compile(
|
||||
|
||||
|
||||
async def _dealgod_supply(db, release_ids):
|
||||
"""Batch DealGod /api/supply → {release_id(str): {store_count, discogs_seller_count, …}}."""
|
||||
key = await vault.get_secret(db, "dealgod_api_key")
|
||||
if not key or not release_ids:
|
||||
return {}
|
||||
out = {}
|
||||
async with httpx.AsyncClient(timeout=25, headers={"X-API-Key": key}) as c:
|
||||
for i in range(0, len(release_ids), 200):
|
||||
try:
|
||||
r = await c.post("https://api.dealgod.pro/api/supply",
|
||||
json={"ids": release_ids[i:i + 200]})
|
||||
if r.status_code == 200:
|
||||
out.update(r.json().get("results", {}))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
"""Batch DealGod /api/supply → {release_id(str): {store_count, discogs_seller_count, …}}.
|
||||
Now delegates to the shared DealGod client (app/dealgod.py) — the single seam to the brain."""
|
||||
from . import dealgod
|
||||
return await dealgod.supply(db, release_ids)
|
||||
|
||||
|
||||
class WishItem(BaseModel):
|
||||
@ -402,6 +393,139 @@ class InvItemIn(BaseModel):
|
||||
in_stock: bool = True
|
||||
|
||||
|
||||
@router.get("/value/{sku}")
|
||||
async def dealgod_value(sku: str, ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""DealGod value overlay for one item — records resolve via release_id, everything else via canon_id
|
||||
(the StoreGod generalisation). Canon goods also get lore (tier/faults). Gated on the pack's 'value' feature."""
|
||||
from . import dealgod, packs
|
||||
if not packs.has_feature("value"):
|
||||
return {"ok": False, "reason": "value not enabled for this pack"}
|
||||
row = (await db.execute(text(
|
||||
"SELECT release_id, canon_id, kind, condition_type FROM inventory WHERE sku = :s AND store_id = :st"),
|
||||
{"s": sku, "st": ident["store_id"]})).mappings().first()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
cond = "new" if row["condition_type"] == "new" else "used-good"
|
||||
if row["release_id"]:
|
||||
val = await dealgod.value(db, release_id=row["release_id"], condition=cond)
|
||||
return {"ok": True, "value": val, "ref": {"type": "release", "id": row["release_id"]}}
|
||||
if row["canon_id"]:
|
||||
val = await dealgod.value(db, canon_id=row["canon_id"], condition=cond)
|
||||
lore = await dealgod.lore(db, row["canon_id"]) if packs.has_feature("lore") else None
|
||||
return {"ok": True, "value": val, "lore": lore, "ref": {"type": "canon", "id": row["canon_id"]}}
|
||||
return {"ok": True, "value": None,
|
||||
"note": "no DealGod ref — identify it first (records→release_id, others→canon_id)"}
|
||||
|
||||
|
||||
class IdentifyIn(BaseModel):
|
||||
clues: dict = {}
|
||||
scope: list[str] | None = None
|
||||
|
||||
|
||||
@router.post("/identify")
|
||||
async def dealgod_identify(body: IdentifyIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""Universal 'what is this' for the back-office → DealGod /api/identify. Defaults scope to the active
|
||||
pack's domains (records bypass this — they use the Discogs matcher). Returns normalised candidates."""
|
||||
from . import dealgod, packs
|
||||
if not packs.has_feature("identify"):
|
||||
return {"ok": False, "reason": "identify not enabled for this pack"}
|
||||
scope = body.scope or packs.active_scope()
|
||||
if scope == ["*"]:
|
||||
scope = None # vanilla StoreGod base → no domain filter
|
||||
r = await dealgod.identify(db, body.clues, scope=scope)
|
||||
return {"ok": True, **r}
|
||||
|
||||
|
||||
class StageIdentifyIn(BaseModel):
|
||||
ref_type: str = "canon" # canon | release
|
||||
ref_id: int
|
||||
title: str | None = None
|
||||
kind: str = "other"
|
||||
identifier: str | None = None # the scanned barcode/ISBN
|
||||
price: float | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@router.post("/intake/identify")
|
||||
async def intake_identify(body: StageIdentifyIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""Stage an item from an /api/identify candidate — the universal intake for non-record packs
|
||||
(records keep their Discogs intake). release → release_id, canon → canon_id."""
|
||||
from .intake_routes import _stage
|
||||
rid = body.ref_id if body.ref_type == "release" else None
|
||||
canon = body.ref_id if body.ref_type == "canon" else None
|
||||
res = await _stage(db, ident["store_id"], rid, None, body.condition, None, body.price, None,
|
||||
kind=body.kind, identifier=body.identifier, canon_id=canon, title=body.title)
|
||||
await db.commit()
|
||||
return {"ok": True, **res}
|
||||
|
||||
|
||||
def _purity(metal: str, karat: float) -> float:
|
||||
if metal == "gold":
|
||||
return max(0.0, min(1.0, karat / 24)) # karat out of 24 (9/14/18/22/24)
|
||||
k = karat # silver/platinum: fineness — 925 / 92.5 / 0.925 all → 0.925
|
||||
if k > 100:
|
||||
k /= 1000
|
||||
elif k > 1:
|
||||
k /= 100
|
||||
return max(0.0, min(1.0, k))
|
||||
|
||||
|
||||
@router.get("/melt")
|
||||
async def melt(metal: str = "gold", karat: float = 18, grams: float = 0,
|
||||
ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""The jewellery module's signature tool — live metal MELT value (the floor you never sell below).
|
||||
melt = grams × spot/oz × purity, using DealGod's live AU metal prices. Gated on the 'melt' feature."""
|
||||
from . import dealgod, packs
|
||||
if not packs.has_feature("melt"):
|
||||
return {"ok": False, "reason": "melt not enabled — unlock the jewellery module"}
|
||||
mp = await dealgod.metal_prices(db)
|
||||
if not mp:
|
||||
return {"ok": False, "reason": "metal prices unavailable"}
|
||||
metal = (metal or "gold").lower()
|
||||
spot = {"gold": mp.get("gold_aud_oz"), "silver": mp.get("silver_aud_oz"),
|
||||
"platinum": mp.get("platinum_aud_oz")}.get(metal)
|
||||
if not spot:
|
||||
return {"ok": False, "reason": "metal must be gold|silver|platinum"}
|
||||
purity = _purity(metal, karat)
|
||||
per_gram = round(spot / 31.1035 * purity, 3)
|
||||
return {"ok": True, "metal": metal, "karat": karat, "grams": grams, "purity": round(purity, 4),
|
||||
"per_gram": per_gram, "melt": round(per_gram * (grams or 0), 2),
|
||||
"spot_oz": spot, "captured_at": mp.get("captured_at")}
|
||||
|
||||
|
||||
class PriceGuideIn(BaseModel):
|
||||
query: str
|
||||
scope: list[str] | None = None
|
||||
|
||||
|
||||
@router.post("/priceguide")
|
||||
async def priceguide(body: PriceGuideIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""Specialist intel lookup — identify the thing, then compose value + lore (variants/faults/id_keys) + supply.
|
||||
The price guide a reseller checks BEFORE they buy. Scope defaults to the active modules' domains."""
|
||||
from . import dealgod, packs
|
||||
if not packs.has_feature("value"):
|
||||
return {"ok": False, "reason": "value not enabled"}
|
||||
q = (body.query or "").strip()
|
||||
if not q:
|
||||
return {"ok": True, "candidate": None}
|
||||
digits = re.sub(r"\D", "", q)
|
||||
clue = ({"barcode": digits} if len(digits) >= 12 else {"isbn": digits}) if re.fullmatch(r"[0-9\s-]{8,}", q) else {"text": q}
|
||||
scope = body.scope or packs.active_scope()
|
||||
if scope == ["*"]:
|
||||
scope = None
|
||||
idr = await dealgod.identify(db, clue, scope=scope)
|
||||
cands = idr.get("candidates") or []
|
||||
if not cands:
|
||||
return {"ok": True, "candidate": None, "reason": idr.get("reason") or idr.get("status")}
|
||||
c = cands[0]
|
||||
val = await dealgod.value(db, ref_type=c.get("ref_type"), ref_id=c.get("ref_id"))
|
||||
lore = await dealgod.lore(db, c.get("ref_id")) if (c.get("ref_type") == "canon" and packs.has_feature("lore")) else None
|
||||
sup = None
|
||||
if c.get("ref_type") == "release":
|
||||
sup = (await dealgod.supply(db, [c["ref_id"]])).get(str(c["ref_id"]))
|
||||
return {"ok": True, "candidate": c, "value": val, "lore": lore, "supply": sup, "others": cands[1:4]}
|
||||
|
||||
|
||||
class InvEditIn(BaseModel):
|
||||
price: float | None = None
|
||||
cost_price: float | None = None
|
||||
@ -823,6 +947,210 @@ async def system(ident=Depends(require_admin), db=Depends(get_db)):
|
||||
return {"db": info, "tables": tables, "fresh": fresh}
|
||||
|
||||
|
||||
@router.get("/slow-queries")
|
||||
async def slow_queries(ident=Depends(require_admin), db=Depends(get_db)):
|
||||
"""The Query Monitor replacement — slowest SQL by mean time, straight from pg_stat_statements (the real tool)."""
|
||||
try:
|
||||
rows = [dict(r) for r in (await db.execute(text("""
|
||||
SELECT left(query, 200) AS query, calls,
|
||||
round(mean_exec_time::numeric, 2) AS mean_ms,
|
||||
round(total_exec_time::numeric, 0) AS total_ms,
|
||||
round(max_exec_time::numeric, 1) AS max_ms, rows
|
||||
FROM pg_stat_statements
|
||||
WHERE query NOT ILIKE '%pg_stat_statements%'
|
||||
AND query NOT ILIKE 'BEGIN%' AND query NOT ILIKE 'COMMIT%' AND query NOT ILIKE 'SET %'
|
||||
ORDER BY mean_exec_time DESC LIMIT 30
|
||||
"""))).mappings()]
|
||||
return {"ok": True, "queries": rows}
|
||||
except Exception as e:
|
||||
return {"ok": False, "reason": f"pg_stat_statements unavailable: {e}"}
|
||||
|
||||
|
||||
@router.post("/slow-queries/reset")
|
||||
async def slow_queries_reset(ident=Depends(require_admin), db=Depends(get_db)):
|
||||
try:
|
||||
await db.execute(text("SELECT pg_stat_statements_reset()"))
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "reason": str(e)}
|
||||
|
||||
|
||||
@router.get("/payment-providers")
|
||||
async def payment_providers(ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""Which processors this store can take — the checkout offers exactly these (provider-agnostic: the anti-Ghost)."""
|
||||
from . import payments
|
||||
return {"ok": True, "providers": await payments.enabled_providers(db)}
|
||||
|
||||
|
||||
class PayIn(BaseModel):
|
||||
provider: str = "mock"
|
||||
token: str # the tokenised card from the frontend SDK (never raw card data)
|
||||
amount: float # dollars
|
||||
currency: str = "AUD"
|
||||
reference: str | None = None
|
||||
|
||||
|
||||
@router.post("/pay")
|
||||
async def pay(body: PayIn, ident=Depends(require_admin), db=Depends(get_db)):
|
||||
"""Virtual terminal — charge a tokenised card via ANY configured processor (Square/Stripe/PayPal/mock).
|
||||
Card data is tokenised client-side; we only ever see the token. Amount in dollars → charged in cents."""
|
||||
from . import payments
|
||||
return await payments.charge(db, body.provider, body.token, int(round(body.amount * 100)),
|
||||
body.currency, body.reference)
|
||||
|
||||
|
||||
# ── Content pages (the WordPress Pages replacement) ───────────────────────────────────────────
|
||||
class PageIn(BaseModel):
|
||||
slug: str
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
published: bool = True
|
||||
seo_title: str | None = None
|
||||
seo_description: str | None = None
|
||||
nav_order: int = 0
|
||||
|
||||
|
||||
@router.get("/pages")
|
||||
async def pages_list(ident=Depends(require_token), db=Depends(get_db)):
|
||||
rows = [dict(r) for r in (await db.execute(text(
|
||||
"SELECT id, slug, title, published, nav_order, updated_at, length(body) AS body_len "
|
||||
"FROM page ORDER BY nav_order, slug"))).mappings()]
|
||||
return {"ok": True, "pages": rows}
|
||||
|
||||
|
||||
@router.get("/pages/{slug}")
|
||||
async def page_get(slug: str, ident=Depends(require_token), db=Depends(get_db)):
|
||||
r = (await db.execute(text("SELECT * FROM page WHERE slug = :s"), {"s": slug})).mappings().first()
|
||||
return {"ok": True, "page": dict(r) if r else None}
|
||||
|
||||
|
||||
@router.post("/pages")
|
||||
async def page_save(body: PageIn, ident=Depends(require_admin), db=Depends(get_db)):
|
||||
slug = re.sub(r"[^a-z0-9-]+", "-", body.slug.lower()).strip("-") or "page"
|
||||
await db.execute(text("""
|
||||
INSERT INTO page (slug, title, body, published, seo_title, seo_description, nav_order, updated_at)
|
||||
VALUES (:s, :t, :b, :p, :st, :sd, :no, now())
|
||||
ON CONFLICT (slug) DO UPDATE SET title=:t, body=:b, published=:p, seo_title=:st,
|
||||
seo_description=:sd, nav_order=:no, updated_at=now()"""),
|
||||
{"s": slug, "t": body.title, "b": body.body, "p": body.published,
|
||||
"st": body.seo_title, "sd": body.seo_description, "no": body.nav_order})
|
||||
await db.commit()
|
||||
return {"ok": True, "slug": slug}
|
||||
|
||||
|
||||
@router.delete("/pages/{slug}")
|
||||
async def page_delete(slug: str, ident=Depends(require_admin), db=Depends(get_db)):
|
||||
await db.execute(text("DELETE FROM page WHERE slug = :s"), {"s": slug})
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 301 redirect map (old WP/Woo URLs → new) — SEO preservation at cutover ─────────────────────
|
||||
class RedirectRow(BaseModel):
|
||||
from_path: str
|
||||
to_path: str
|
||||
code: int = 301
|
||||
|
||||
|
||||
class RedirectsIn(BaseModel):
|
||||
redirects: list[RedirectRow]
|
||||
|
||||
|
||||
@router.get("/redirects")
|
||||
async def redirects_list(ident=Depends(require_admin), db=Depends(get_db)):
|
||||
rows = [dict(r) for r in (await db.execute(text(
|
||||
"SELECT from_path, to_path, code, hits FROM url_redirect ORDER BY hits DESC, from_path LIMIT 500"))).mappings()]
|
||||
return {"ok": True, "redirects": rows}
|
||||
|
||||
|
||||
@router.post("/redirects")
|
||||
async def redirects_save(body: RedirectsIn, ident=Depends(require_admin), db=Depends(get_db)):
|
||||
n = 0
|
||||
for r in body.redirects:
|
||||
fp, tp = r.from_path.strip(), r.to_path.strip()
|
||||
if not fp or not tp or fp == tp:
|
||||
continue
|
||||
if not fp.startswith("/"):
|
||||
fp = "/" + fp
|
||||
await db.execute(text("""INSERT INTO url_redirect (from_path, to_path, code) VALUES (:f, :t, :c)
|
||||
ON CONFLICT (from_path) DO UPDATE SET to_path = :t, code = :c"""),
|
||||
{"f": fp, "t": tp, "c": r.code or 301})
|
||||
n += 1
|
||||
await db.commit()
|
||||
return {"ok": True, "saved": n}
|
||||
|
||||
|
||||
@router.delete("/redirects")
|
||||
async def redirect_delete(from_path: str = Query(...), ident=Depends(require_admin), db=Depends(get_db)):
|
||||
await db.execute(text("DELETE FROM url_redirect WHERE from_path = :f"), {"f": from_path})
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Discogs Marketplace Manager (manage YOUR listings + orders from StoreGod) ──────────────────
|
||||
@router.get("/discogs")
|
||||
async def discogs_status(ident=Depends(require_token), db=Depends(get_db)):
|
||||
from . import discogs_mp
|
||||
return await discogs_mp.status(db)
|
||||
|
||||
|
||||
@router.get("/discogs/orders")
|
||||
async def discogs_orders(page: int = 1, ident=Depends(require_token), db=Depends(get_db)):
|
||||
from . import discogs_mp
|
||||
return await discogs_mp.orders(db, page)
|
||||
|
||||
|
||||
class DiscogsListIn(BaseModel):
|
||||
status: str = "For Sale" # 'Draft' = safe/not live (use to test); 'For Sale' goes live
|
||||
price: float | None = None
|
||||
|
||||
|
||||
@router.post("/discogs/list/{sku}")
|
||||
async def discogs_list(sku: str, body: DiscogsListIn, ident=Depends(require_admin), db=Depends(get_db)):
|
||||
"""List a StoreGod item on Discogs. Records only (needs a release_id). Stores the listing id for cross-channel sync."""
|
||||
from . import discogs_mp
|
||||
row = (await db.execute(text(
|
||||
"SELECT release_id, price::float AS price, condition, sleeve_cond, discogs_listing_id "
|
||||
"FROM inventory WHERE sku = :s AND store_id = :st"), {"s": sku, "st": ident["store_id"]})).mappings().first()
|
||||
if not row:
|
||||
raise HTTPException(404, "not found")
|
||||
if not row["release_id"]:
|
||||
return {"ok": False, "error": "no release_id — Discogs listings need a Discogs release"}
|
||||
if row["discogs_listing_id"]:
|
||||
return {"ok": False, "error": f"already listed (#{row['discogs_listing_id']})"}
|
||||
price = body.price if body.price is not None else (row["price"] or 0)
|
||||
if price <= 0:
|
||||
return {"ok": False, "error": "set a price first"}
|
||||
res = await discogs_mp.create_listing(db, row["release_id"], price, row["condition"], row["sleeve_cond"], body.status)
|
||||
if res.get("ok"):
|
||||
await db.execute(text("UPDATE inventory SET discogs_listing_id = :l WHERE sku = :s"),
|
||||
{"l": res["listing_id"], "s": sku})
|
||||
await db.commit()
|
||||
return res
|
||||
|
||||
|
||||
@router.delete("/discogs/list/{sku}")
|
||||
async def discogs_delist(sku: str, ident=Depends(require_admin), db=Depends(get_db)):
|
||||
from . import discogs_mp
|
||||
lid = (await db.execute(text("SELECT discogs_listing_id FROM inventory WHERE sku = :s AND store_id = :st"),
|
||||
{"s": sku, "st": ident["store_id"]})).scalar()
|
||||
if not lid:
|
||||
return {"ok": True, "note": "not listed"}
|
||||
res = await discogs_mp.delete_listing(db, lid)
|
||||
if res.get("ok"):
|
||||
await db.execute(text("UPDATE inventory SET discogs_listing_id = NULL WHERE sku = :s"), {"s": sku})
|
||||
await db.commit()
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/discogs/sync")
|
||||
async def discogs_sync(ident=Depends(require_admin), db=Depends(get_db)):
|
||||
"""Pull new Discogs orders → mark sold + record the sale (idempotent). The other half of anti-oversell."""
|
||||
from . import discogs_mp
|
||||
return await discogs_mp.sync_orders(db)
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
async def orders(ident=Depends(require_token), db=Depends(get_db)):
|
||||
base = await vault.get_secret(db, "woo_base_url")
|
||||
|
||||
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 pathlib
|
||||
|
||||
log = logging.getLogger("recordgod")
|
||||
|
||||
# Load .env FIRST so auth/db/vault see their keys at import time.
|
||||
_ENV = pathlib.Path(__file__).resolve().parent.parent / ".env"
|
||||
if _ENV.exists():
|
||||
@ -10,9 +13,9 @@ if _ENV.exists():
|
||||
_k, _v = _ln.split("=", 1)
|
||||
os.environ.setdefault(_k.strip(), _v.strip())
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi import FastAPI, Request # noqa: E402
|
||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||
from fastapi.responses import FileResponse # noqa: E402
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response, PlainTextResponse # noqa: E402
|
||||
|
||||
from . import __version__ # noqa: E402
|
||||
from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402
|
||||
@ -110,6 +113,20 @@ _STARTUP_DDL = [
|
||||
"ALTER TABLE inventory ADD COLUMN IF NOT EXISTS cost_price numeric(10,2)",
|
||||
"ALTER TABLE inventory ADD COLUMN IF NOT EXISTS cost_source text", # e.g. 'rarewaves #592619'
|
||||
"ALTER TABLE inventory ADD COLUMN IF NOT EXISTS condition_type text NOT NULL DEFAULT 'used'", # 'new' | 'used'
|
||||
"ALTER TABLE inventory ADD COLUMN IF NOT EXISTS canon_id bigint", # DealGod canon ref for non-record goods (records use release_id) — the StoreGod generalisation
|
||||
"ALTER TABLE inventory ADD COLUMN IF NOT EXISTS discogs_listing_id bigint", # cross-channel: this item's live Discogs marketplace listing
|
||||
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS discogs_order_id text", # imported Discogs order (idempotency key for order sync)
|
||||
"CREATE INDEX IF NOT EXISTS inventory_discogs_listing_idx ON inventory (discogs_listing_id) WHERE discogs_listing_id IS NOT NULL",
|
||||
# content pages (the WordPress Pages replacement — About/Shipping/Returns/Privacy/…)
|
||||
"""CREATE TABLE IF NOT EXISTS page (
|
||||
id bigserial PRIMARY KEY, slug text UNIQUE NOT NULL, title text NOT NULL DEFAULT '',
|
||||
body text NOT NULL DEFAULT '', published boolean NOT NULL DEFAULT true,
|
||||
seo_title text, seo_description text, nav_order int NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now())""",
|
||||
# 301 redirect map — preserve SEO/bookmarks when cutting over from old WP/Woo URLs
|
||||
"""CREATE TABLE IF NOT EXISTS url_redirect (
|
||||
from_path text PRIMARY KEY, to_path text NOT NULL, code int NOT NULL DEFAULT 301,
|
||||
hits int NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now())""",
|
||||
# normalised-barcode lookup (DB barcodes stored inconsistently: '5 018775 901762' vs clean digits)
|
||||
"CREATE INDEX IF NOT EXISTS disc_rel_id_barcode_norm ON disc_release_identifier "
|
||||
"(regexp_replace(value,'[^0-9]','','g')) WHERE type='Barcode'",
|
||||
@ -123,15 +140,27 @@ _STARTUP_DDL = [
|
||||
"ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama text",
|
||||
"ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_radius numeric",
|
||||
"ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_color text",
|
||||
"ALTER TABLE virtual_space ADD COLUMN IF NOT EXISTS cyclorama_extent numeric", # east/west coves: limit to the cyc section
|
||||
]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _ensure_tables():
|
||||
# lazy migrations on deploy (store_config + POS columns/sequences)
|
||||
async with engine.begin() as conn:
|
||||
for _stmt in _STARTUP_DDL:
|
||||
await conn.execute(_sqltext(_stmt))
|
||||
# Lazy idempotent migrations on deploy. Each statement runs in its OWN transaction so a
|
||||
# single failure (e.g. an ALTER on a table a prior step hasn't created yet) is isolated and
|
||||
# logged — it can't abort the whole batch and boot the app with NO schema. All statements are
|
||||
# IF-NOT-EXISTS/idempotent, so a skipped one is simply retried (and may succeed) on next boot.
|
||||
ok = failed = 0
|
||||
for _stmt in _STARTUP_DDL:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(_sqltext(_stmt))
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
log.warning("startup DDL skipped: %s … — %s", _stmt[:70].replace("\n", " "), e)
|
||||
if failed:
|
||||
log.warning("startup DDL: %d applied, %d skipped (see above)", ok, failed)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
@ -139,6 +168,50 @@ async def healthz():
|
||||
return {"ok": True, "service": "recordgod", "version": __version__}
|
||||
|
||||
|
||||
async def _site_base(request: Request) -> str:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
su = (await conn.execute(_sqltext(
|
||||
"SELECT setting_value FROM sales_setting WHERE setting_key='store_url' AND is_active"))).scalar()
|
||||
if su:
|
||||
return su.rstrip("/")
|
||||
except Exception:
|
||||
pass
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
@app.get("/robots.txt", include_in_schema=False)
|
||||
async def robots(request: Request):
|
||||
base = await _site_base(request)
|
||||
return PlainTextResponse(f"User-agent: *\nAllow: /\nSitemap: {base}/sitemap.xml\n")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml", include_in_schema=False)
|
||||
async def sitemap(request: Request):
|
||||
base = await _site_base(request)
|
||||
paths = ["/", "/shop"]
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
for (slug,) in (await conn.execute(_sqltext("SELECT slug FROM page WHERE published"))).all():
|
||||
paths.append(f"/shop/page/{slug}")
|
||||
for (rid,) in (await conn.execute(_sqltext(
|
||||
"SELECT DISTINCT release_id FROM inventory WHERE in_stock AND release_id IS NOT NULL LIMIT 10000"))).all():
|
||||
paths.append(f"/release/{rid}")
|
||||
except Exception:
|
||||
pass
|
||||
locs = "".join(f"<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
|
||||
|
||||
|
||||
@ -158,8 +231,45 @@ app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"
|
||||
# entity browse pages — records.html reads the kind+value from the path (/artist/9, /genre/House…)
|
||||
for _ent in ("artist", "label", "genre", "style"):
|
||||
app.add_api_route(f"/{_ent}/{{val}}", _page("records.html"), methods=["GET"], include_in_schema=False)
|
||||
# the login landing too (no-cache), matched before the "/" static mount below
|
||||
app.add_api_route("/", _page("index.html"), methods=["GET"], include_in_schema=False)
|
||||
# "/" honours the homepage setting (Store Settings → Homepage): storefront → /shop, 3d → /store,
|
||||
# else the index.html landing. Defaults safe (landing) if unset/unreadable.
|
||||
async def _home():
|
||||
hp = None
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
hp = (await conn.execute(_sqltext(
|
||||
"SELECT setting_value FROM sales_setting WHERE setting_key='homepage' AND is_active"))).scalar()
|
||||
except Exception:
|
||||
pass
|
||||
if hp == "storefront":
|
||||
return RedirectResponse("/shop")
|
||||
if hp == "3d":
|
||||
return RedirectResponse("/store")
|
||||
return FileResponse(_ROOT / "site" / "index.html", headers={"Cache-Control": "no-cache, must-revalidate"})
|
||||
|
||||
|
||||
app.add_api_route("/", _home, methods=["GET"], include_in_schema=False)
|
||||
|
||||
|
||||
# 404 → check the redirect map (old WP/Woo URLs → new ones) before giving up. Keeps SEO + bookmarks alive at cutover.
|
||||
from starlette.exceptions import HTTPException as _StarletteHTTPException # noqa: E402
|
||||
from fastapi.responses import JSONResponse # noqa: E402
|
||||
|
||||
|
||||
@app.exception_handler(_StarletteHTTPException)
|
||||
async def _on_http_exc(request, exc):
|
||||
if exc.status_code == 404:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
row = (await conn.execute(_sqltext(
|
||||
"UPDATE url_redirect SET hits = hits + 1 WHERE from_path = :p RETURNING to_path, code"),
|
||||
{"p": request.url.path})).first()
|
||||
await conn.commit()
|
||||
if row:
|
||||
return RedirectResponse(row[0], status_code=row[1] or 301)
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
||||
|
||||
# Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.
|
||||
if (_ROOT / "webstore").is_dir():
|
||||
|
||||
@ -36,7 +36,7 @@ async def store_layout(space_id: int | None = None, ident=Depends(require_token)
|
||||
"""), {"s": space_id})).mappings().first()
|
||||
racks = [dict(r) for r in (await db.execute(text("""
|
||||
SELECT r.id, r.name, r.pos_x::float AS x, r.pos_z::float AS z,
|
||||
r.rotation_y::float AS rot, r.direction,
|
||||
r.rotation_y::float AS rot, r.direction, r.attach_wall,
|
||||
rt.type AS rack_type, rt.name AS type_name,
|
||||
rt.width::float AS w, rt.depth::float AS d, coalesce(rt.levels,1)::int AS levels,
|
||||
(SELECT count(*) FROM virtual_crate c WHERE c.rack_id = r.id AND c.visible = 'y') AS crates,
|
||||
@ -88,6 +88,8 @@ class RackEditIn(BaseModel):
|
||||
pos_z: float | None = None
|
||||
direction: str | None = None # forward | back | left | right
|
||||
rotation_y: float | None = None # fine rotation (degrees)
|
||||
space_id: int | None = None # move the whole rack (+ its crates) to another room
|
||||
attach_wall: str | None = None # '' to free a wall-attached rack (the editor folds facing into rotation_y)
|
||||
|
||||
|
||||
@router.post("/rack/{rack_id}")
|
||||
@ -97,6 +99,9 @@ async def rack_edit(rack_id: int, body: RackEditIn, ident=Depends(require_token)
|
||||
return {"ok": True, "unchanged": True}
|
||||
sets = ", ".join(f"{k} = :{k}" for k in fields) + ", updated_at = now()"
|
||||
r = await db.execute(text(f"UPDATE virtual_rack SET {sets} WHERE id = :i"), {**fields, "i": rack_id})
|
||||
if "space_id" in fields: # the rack's crates are slot-positioned children — keep them in the same room
|
||||
await db.execute(text("UPDATE virtual_crate SET space_id = :s WHERE rack_id = :i"),
|
||||
{"s": fields["space_id"], "i": rack_id})
|
||||
await db.commit()
|
||||
if not r.rowcount:
|
||||
raise HTTPException(404, "rack not found")
|
||||
|
||||
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
|
||||
"""), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]})
|
||||
await db.commit()
|
||||
if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort)
|
||||
try:
|
||||
from . import discogs_mp
|
||||
await discogs_mp.delist_skus(db, [li.sku for li in body.items])
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "sale_id": sale_id, "sale_number": sale_number, "total": total,
|
||||
"amount_paid": amount_paid, "balance": round(total - amount_paid, 2), "status": status}
|
||||
|
||||
@ -276,18 +282,25 @@ async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Dep
|
||||
return {"ok": True, "manual_active": row[0]}
|
||||
|
||||
|
||||
# ── Settings (currency / tax / default discount) ─────────────────────────────────────────────
|
||||
# ── Store Settings — the WordPress General/Reading replacement (one screen, kv-backed) ─────────
|
||||
_SETTING_DEFAULTS = {
|
||||
"store_name": "RecordGod", "store_tagline": "", "store_logo": "", "store_address": "",
|
||||
"contact_email": "", "contact_phone": "", "abn": "", "timezone": "Australia/Sydney",
|
||||
"store_url": "", "social_instagram": "", "social_facebook": "",
|
||||
"currency_symbol": "$", "tax_rate": "0", "tax_type": "exclusive", "discount_rate": "0",
|
||||
"policy_returns": "", "policy_shipping": "", "policy_privacy": "",
|
||||
"receipt_footer": "thanks for digging", "homepage": "storefront",
|
||||
"seo_title": "", "seo_description": "",
|
||||
"meta_pixel_id": "", "umami_website_id": "", "umami_src": "", "ga4_id": "",
|
||||
}
|
||||
# some keys are stored under legacy names in the kv table
|
||||
_SETTING_ALIASES = {"tax_rate": "default_tax_rate", "discount_rate": "default_discount_rate"}
|
||||
|
||||
|
||||
async def _settings(db):
|
||||
rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active"))
|
||||
s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()}
|
||||
return {"currency_symbol": s.get("currency_symbol", "$"),
|
||||
"tax_rate": s.get("default_tax_rate", s.get("tax_rate", "0")),
|
||||
"discount_rate": s.get("default_discount_rate", "0"),
|
||||
"tax_type": s.get("tax_type", "exclusive"),
|
||||
"store_name": s.get("store_name", "RecordGod"),
|
||||
"store_address": s.get("store_address", ""),
|
||||
"receipt_footer": s.get("receipt_footer", "thanks for digging"),
|
||||
"store_logo": s.get("store_logo", "")}
|
||||
return {k: s.get(_SETTING_ALIASES.get(k, k), s.get(k, d)) for k, d in _SETTING_DEFAULTS.items()}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
@ -297,17 +310,14 @@ async def get_settings(ident=Depends(require_token), db=Depends(get_db)):
|
||||
|
||||
@router.post("/settings")
|
||||
async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)):
|
||||
m = {"currency_symbol": body.get("currency_symbol"), "default_tax_rate": body.get("tax_rate"),
|
||||
"default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type"),
|
||||
"store_name": body.get("store_name"), "store_address": body.get("store_address"),
|
||||
"receipt_footer": body.get("receipt_footer"), "store_logo": body.get("store_logo")}
|
||||
for k, v in m.items():
|
||||
if v is None:
|
||||
for k, v in body.items():
|
||||
if k not in _SETTING_DEFAULTS or v is None:
|
||||
continue
|
||||
await db.execute(text("""
|
||||
INSERT INTO sales_setting (setting_key, setting_value, updated_at) VALUES (:k, :v, now())
|
||||
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now()
|
||||
"""), {"k": k, "v": str(v)})
|
||||
INSERT INTO sales_setting (setting_key, setting_value, is_active, updated_at)
|
||||
VALUES (:k, :v, true, now())
|
||||
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, is_active = true, updated_at = now()
|
||||
"""), {"k": _SETTING_ALIASES.get(k, k), "v": str(v)})
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, Header
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
@ -28,6 +32,56 @@ from .layout_routes import DEFAULTS
|
||||
router = APIRouter(prefix="/shop", tags=["shop"])
|
||||
|
||||
|
||||
async def _tracking_head(db) -> str:
|
||||
"""Analytics/marketing snippets for the storefront <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")
|
||||
async def shop_config(db=Depends(get_db)):
|
||||
row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first()
|
||||
@ -340,6 +394,84 @@ async def shop_shipping_quote(units: int | None = None, weight_g: int | None = N
|
||||
return {"weight_g": weight_g, "parcels": parcels, "options": opts}
|
||||
|
||||
|
||||
class CheckoutItem(BaseModel):
|
||||
sku: str
|
||||
qty: int = 1
|
||||
|
||||
|
||||
class CheckoutIn(BaseModel):
|
||||
items: list[CheckoutItem]
|
||||
provider: str = "mock"
|
||||
token: str # tokenised card from the frontend SDK (never raw card data)
|
||||
email: str | None = None
|
||||
name: str | None = None
|
||||
pickup: bool = False # skip postage
|
||||
|
||||
|
||||
@router.post("/checkout")
|
||||
async def checkout(body: CheckoutIn, db=Depends(get_db)):
|
||||
"""Online checkout — the WooCommerce replacement. SERVER-SIDE priced (never trusts the client), GST-inclusive
|
||||
(AU), cheapest AusPost postage, charged via ANY configured processor, writes the order + marks stock sold."""
|
||||
from . import payments
|
||||
if not body.items:
|
||||
raise HTTPException(400, "empty cart")
|
||||
if body.provider not in await payments.enabled_providers(db):
|
||||
raise HTTPException(403, f"payment provider '{body.provider}' is not available")
|
||||
rows = {r["sku"]: dict(r) for r in (await db.execute(text("""
|
||||
SELECT i.sku, coalesce(i.title, dc.title) AS title, i.price::float AS price, i.in_stock,
|
||||
i.weight_g::float AS weight_g, i.release_id
|
||||
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
||||
WHERE i.sku = ANY(:skus) AND i.store_id = 1"""), {"skus": [i.sku for i in body.items]})).mappings()}
|
||||
lines, subtotal, weight = [], 0.0, 0.0
|
||||
for it in body.items:
|
||||
r = rows.get(it.sku)
|
||||
if not r:
|
||||
raise HTTPException(400, f"unknown item {it.sku}")
|
||||
if not r["in_stock"]:
|
||||
raise HTTPException(409, f"{r.get('title') or it.sku} is already sold")
|
||||
qty, price = max(1, it.qty), float(r["price"] or 0)
|
||||
subtotal += price * qty
|
||||
weight += (r["weight_g"] or 280) * qty
|
||||
lines.append({"sku": it.sku, "title": r.get("title"), "release_id": r.get("release_id"), "qty": qty, "price": price})
|
||||
shipping = 0.0
|
||||
if not body.pickup:
|
||||
wg = int(weight) + 50 * len(lines) # +packaging
|
||||
parcels = max(1, -(-wg // 5000)); per = -(-wg // parcels)
|
||||
cheapest = ((await db.execute(text(
|
||||
"SELECT min(price)::float AS p FROM post_flat_rate WHERE :w BETWEEN weight_min_g AND weight_max_g"),
|
||||
{"w": per})).mappings().first() or {}).get("p")
|
||||
shipping = round((cheapest or 0) * parcels, 2)
|
||||
total = round(subtotal + shipping, 2)
|
||||
gst = round(total / 11, 2) # AU GST-inclusive component (for the order record)
|
||||
ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S")
|
||||
pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref)
|
||||
if not pay.get("ok"):
|
||||
return {"ok": False, "stage": "payment", "error": pay.get("error")}
|
||||
sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
|
||||
sale_id = (await db.execute(text("""
|
||||
INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method,
|
||||
payment_status, amount_paid, notes, sale_date, created_at)
|
||||
VALUES (:sn, :sub, 0, :tax, :total, 'publish', :pm, 'paid', :total, :notes, now(), now()) RETURNING id"""),
|
||||
{"sn": sn, "sub": round(subtotal, 2), "tax": gst, "total": total, "pm": pay["provider"],
|
||||
"notes": f"online · pay {pay.get('payment_id')} · ship ${shipping}" + (f" · {body.email}" if body.email else "")})).first()[0]
|
||||
for li in lines:
|
||||
await db.execute(text("""INSERT INTO sale_items (sale_id, sku, release_id, item_name, qty, unit_price, line_total)
|
||||
VALUES (:s,:sku,:rid,:nm,:q,:up,:lt)"""),
|
||||
{"s": sale_id, "sku": li["sku"], "rid": li["release_id"], "nm": li["title"],
|
||||
"q": li["qty"], "up": li["price"], "lt": round(li["price"] * li["qty"], 2)})
|
||||
await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now() WHERE sku=:sku AND store_id=1"),
|
||||
{"sku": li["sku"]})
|
||||
await db.commit()
|
||||
try: # anti-oversell: delist these from Discogs now they've sold online (best-effort)
|
||||
from . import discogs_mp
|
||||
await discogs_mp.delist_skus(db, [li["sku"] for li in lines])
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "order": sn, "sale_id": sale_id, "subtotal": round(subtotal, 2),
|
||||
"shipping": shipping, "gst": gst, "total": total,
|
||||
"payment": {"provider": pay["provider"], "id": pay.get("payment_id")}}
|
||||
|
||||
|
||||
class WooOrderIn(BaseModel):
|
||||
order_number: str
|
||||
email: str | None = None
|
||||
|
||||
@ -35,7 +35,7 @@ FROM _woo w WHERE c.wp_user_id = w.wp_user_id;
|
||||
|
||||
-- 3. brand-new online customer (no wp_user_id match AND no email match)
|
||||
INSERT INTO customer (wp_user_id, first_name, last_name, email, phone, address, is_guest)
|
||||
SELECT w.wp_user_id, w.first_name, w.last_name, w.email, w.phone, w.address, false
|
||||
SELECT w.wp_user_id, coalesce(w.first_name,''), coalesce(w.last_name,''), w.email, w.phone, w.address, false
|
||||
FROM _woo w
|
||||
WHERE NOT EXISTS (SELECT 1 FROM customer c WHERE c.wp_user_id = w.wp_user_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM customer c WHERE w.email <> '' AND lower(c.email) = lower(w.email));
|
||||
|
||||
@ -120,9 +120,10 @@ def main():
|
||||
rs.setdefault(r["release_id"], []).append(r["style_id"])
|
||||
|
||||
# releases
|
||||
# live dropped the stored `search_text`; compute it (title + artist) for RecordGod's fuzzy index
|
||||
c.execute(f"""SELECT id, title, artists_sort, released, country, year, notes, format_quantity,
|
||||
description, apple_id, estimated_weight, master_id, thumb, rating_count,
|
||||
rating_average, search_text FROM {P}release""")
|
||||
rating_average, LOWER(CONCAT_WS(' ', title, artists_sort)) AS search_text FROM {P}release""")
|
||||
cols = ["id", "title", "artists_sort", "released", "country", "year", "notes", "format_quantity",
|
||||
"description", "apple_id", "estimated_weight", "master_id", "thumb", "rating_count",
|
||||
"rating_average", "search_text", "genre_ids", "style_ids"]
|
||||
|
||||
@ -94,6 +94,7 @@
|
||||
<button class="ghost" id="lvlNext" onclick="stepLevel(1)">▶</button>
|
||||
</span>
|
||||
<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>
|
||||
<span id="selModeHint" class="selmode" style="display:none">● Select Mode — click crates in order</span>
|
||||
</div>
|
||||
@ -161,6 +162,22 @@ function directionYaw(d){ d=(d||'').toLowerCase();
|
||||
if(d==='right'||d==='east') return -Math.PI/2;
|
||||
return 0; }
|
||||
const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'};
|
||||
// Match the 3D store EXACTLY: stored pos_x/pos_z is a rotated CORNER-anchor, not the centre (webstore l.319-326).
|
||||
function rackGeom(r){ // -> scene-coord rack CENTRE (cx,cz) + effective yaw (rad), so the 2D map == the 3D store
|
||||
const W=+(ST.space&&ST.space.room_width)||12, D=+(ST.space&&ST.space.room_depth)||12;
|
||||
const hx=(r.w||0.5)/2, hz=(r.d||0.5)/2, rpx=+r.x||0, rpz=+r.z||0, a=(r.attach_wall||'').toLowerCase();
|
||||
const rot=(+r.rot||0)*Math.PI/180, base=directionYaw(r.direction)+rot; let yaw=base, cx, cz;
|
||||
if(a==='north'){ yaw=rot; cz=-D/2+hz+Math.max(0,rpz); cx=-W/2+hx+rpx; }
|
||||
else if(a==='south'){ yaw=Math.PI+rot; cz=D/2-hz-Math.max(0,rpz); cx=-W/2+hx+rpx; }
|
||||
else if(a==='east'){ yaw=-Math.PI/2+rot; cx=W/2-hz-Math.max(0,rpz); cz=-D/2+hx+rpx; }
|
||||
else if(a==='west'){ yaw=Math.PI/2+rot; cx=-W/2+hz+Math.max(0,rpz); cz=-D/2+hx+rpx; }
|
||||
else { cx=-W/2+rpx+hx*Math.cos(base)-hz*Math.sin(base); cz=-D/2+rpz+hx*Math.sin(base)+hz*Math.cos(base); }
|
||||
return {cx, cz, yaw};
|
||||
}
|
||||
function geomToPos(r, cx, cz, yaw){ // FREE inverse: desired scene CENTRE -> corner-anchored pos_x/pos_z
|
||||
const W=+(ST.space&&ST.space.room_width)||12, D=+(ST.space&&ST.space.room_depth)||12, hx=(r.w||0.5)/2, hz=(r.d||0.5)/2;
|
||||
return { pos_x: cx+W/2-(hx*Math.cos(yaw)-hz*Math.sin(yaw)), pos_z: cz+D/2-(hx*Math.sin(yaw)+hz*Math.cos(yaw)) };
|
||||
}
|
||||
|
||||
let ST={tab:'scanner', view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[],
|
||||
selCrate:null, hit:[], storeTf:null, itemShown:false,
|
||||
@ -196,8 +213,8 @@ function logout(){
|
||||
localStorage.removeItem('rg_token'); location.reload();
|
||||
}
|
||||
async function loadSpaces(){
|
||||
const d=await get('/nav/spaces');
|
||||
$('#spaceSel').innerHTML=(d.spaces||[]).map(s=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).join('');
|
||||
const d=await get('/nav/spaces'); ST.spaces=d.spaces||[];
|
||||
$('#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();
|
||||
}
|
||||
|
||||
@ -237,12 +254,12 @@ function rotateView(){ ST.viewRot=((ST.viewRot||0)+90)%360; redraw(); }
|
||||
// ───────────── STORE VIEW ─────────────
|
||||
async function toStore(){
|
||||
ST.view='store'; ST.rack=null; ST.selCrate=null; ST.crateInfo=null; renderCrateInfo();
|
||||
$('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none';
|
||||
$('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none'; $('#rackRoomSel').style.display='none';
|
||||
$('#crumb').textContent='Store map';
|
||||
const sid=$('#spaceSel').value;
|
||||
const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):''));
|
||||
ST.space=d.space; ST.racks=d.racks||[];
|
||||
$('#legend').innerHTML='<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();
|
||||
}
|
||||
function drawStore(){
|
||||
@ -250,22 +267,25 @@ function drawStore(){
|
||||
g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H);
|
||||
ST.hit=[];
|
||||
if(!ST.racks.length){ g.fillStyle='#888'; g.font='14px system-ui'; g.textAlign='center'; g.fillText('No racks in this space',W/2,H/2); return; }
|
||||
// each rack's TRUE scene centre + yaw (== the 3D store); _edit* overrides win during a drag/rotate gesture
|
||||
ST.racks.forEach(r=>{ const gm=rackGeom(r); r._cx=r._editCx??gm.cx; r._cz=r._editCz??gm.cz; r._yaw=r._editYaw??gm.yaw; });
|
||||
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9;
|
||||
ST.racks.forEach(r=>{ const hw=(r.w||0.5)/2, hd=(r.d||0.5)/2;
|
||||
minX=Math.min(minX,r.x-hw); maxX=Math.max(maxX,r.x+hw); minZ=Math.min(minZ,r.z-hd); maxZ=Math.max(maxZ,r.z+hd); });
|
||||
ST.racks.forEach(r=>{ const hd=Math.hypot((r.w||0.5)/2,(r.d||0.5)/2);
|
||||
minX=Math.min(minX,r._cx-hd); maxX=Math.max(maxX,r._cx+hd); minZ=Math.min(minZ,r._cz-hd); maxZ=Math.max(maxZ,r._cz+hd); });
|
||||
const pad=34, sc=Math.min((W-2*pad)/((maxX-minX)||1),(H-2*pad)/((maxZ-minZ)||1));
|
||||
ST.storeTf={minX,minZ,sc,pad};
|
||||
const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc;
|
||||
const q=(((ST.viewRot||0)/90)%4+4)%4;
|
||||
ST.racks.forEach(r=>{
|
||||
const yaw=directionYaw(r.direction)+(r.rot||0)*Math.PI/180 + q*Math.PI/2;
|
||||
const yaw=r._yaw + q*Math.PI/2;
|
||||
const hw=(r.w||0.5)/2*sc, hd=(r.d||0.5)/2*sc;
|
||||
let cx=X(r.x), cy=Y(r.z);
|
||||
let cx=X(r._cx), cy=Y(r._cz);
|
||||
if(q){ const a=q*Math.PI/2, co=Math.cos(a), si=Math.sin(a), dx=cx-W/2, dy=cy-H/2; cx=W/2+dx*co-dy*si; cy=H/2+dx*si+dy*co; }
|
||||
const cos=Math.cos(yaw), sin=Math.sin(yaw);
|
||||
const corners=[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([px,pz])=>[cx+px*cos-pz*sin, cy+px*sin+pz*cos]);
|
||||
g.beginPath(); g.moveTo(corners[0][0],corners[0][1]); corners.slice(1).forEach(c=>g.lineTo(c[0],c[1])); g.closePath();
|
||||
g.fillStyle=r.items? '#8bc34a':'#cfe3b0'; g.fill(); g.strokeStyle='#558b2f'; g.lineWidth=1.5; g.stroke();
|
||||
g.beginPath(); g.moveTo(cx,cy); g.lineTo(cx+sin*hd, cy-cos*hd); g.strokeStyle='#33691e'; g.lineWidth=2; g.stroke(); // facing tick
|
||||
g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='middle';
|
||||
const lbl=(r.name||('Rack '+r.id)); g.fillText(lbl.length>15?lbl.slice(0,14)+'…':lbl, cx, cy-(r.items?5:0));
|
||||
if(r.items){ g.fillStyle='#3a3a3a'; g.font='9px system-ui'; g.fillText(r.items+' items', cx, cy+7); }
|
||||
@ -282,6 +302,8 @@ async function openRack(id, focusCrate){
|
||||
: (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1);
|
||||
ST.selCrate=focusCrate||null;
|
||||
$('#rackEditBtn').style.display='';
|
||||
const rrs=$('#rackRoomSel'); rrs.style.display='';
|
||||
rrs.innerHTML=(ST.spaces||[]).map(s=>`<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>';
|
||||
drawRack(); setViewBtns();
|
||||
if(ST.tab==='reorganize') renderPick();
|
||||
@ -356,6 +378,13 @@ async function rackEdit(){
|
||||
const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return;
|
||||
await post('/nav/rack/'+ST.rack.id,{name:n}); openRack(ST.rack.id, ST.selCrate);
|
||||
}
|
||||
async function moveRackRoom(){
|
||||
const to=+$('#rackRoomSel').value; if(!ST.rack || to===ST.rack.space_id) return;
|
||||
const nm=(ST.spaces.find(s=>s.id===to)||{}).name||('Space '+to);
|
||||
if(!confirm(`Move "${ST.rack.name||('Rack '+ST.rack.id)}" and its crates to ${nm}?`)){ $('#rackRoomSel').value=ST.rack.space_id; return; }
|
||||
await post('/nav/rack/'+ST.rack.id,{space_id:to});
|
||||
$('#spaceSel').value=to; toStore(); // jump to the destination room, moved rack in place
|
||||
}
|
||||
|
||||
// ───────────── crate contents (centre column) ─────────────
|
||||
async function loadCrate(id){
|
||||
@ -726,20 +755,30 @@ function hitRack(mx,my){ let best=null,bd=1e9; for(const h of ST.hit){ if(h.type
|
||||
function hitCrate(mx,my){ for(const h of ST.hit){ if(h.type==='crate'&&mx>=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h) return h; } return null; }
|
||||
let down=null;
|
||||
$('#cv').addEventListener('mousedown',e=>{
|
||||
const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null};
|
||||
if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my); if(h) down.dragRack=h.id; }
|
||||
const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null,rot:false};
|
||||
if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my);
|
||||
if(h){ const r=ST.racks.find(x=>x.id===h.id); down.dragRack=h.id; down.rot=e.shiftKey; // drag = move · shift-drag = rotate
|
||||
down.cx=h.cx; down.cy=h.cy; down.startYaw=r._yaw||0; down.startAng=Math.atan2(my-h.cy,mx-h.cx); } }
|
||||
});
|
||||
$('#cv').addEventListener('mousemove',e=>{
|
||||
if(!down||!down.dragRack) return;
|
||||
const {mx,my}=cvXY(e);
|
||||
if(Math.hypot(mx-down.mx,my-down.my)>5) down.moved=true;
|
||||
if(down.moved){ const tf=ST.storeTf, r=ST.racks.find(x=>x.id===down.dragRack), [wmx,wmy]=unrotPt(mx,my);
|
||||
r.x=tf.minX+(wmx-tf.pad)/tf.sc; r.z=tf.minZ+(wmy-tf.pad)/tf.sc; drawStore(); }
|
||||
if(!down.moved) return;
|
||||
const r=ST.racks.find(x=>x.id===down.dragRack), tf=ST.storeTf;
|
||||
if(down.rot){ r._editYaw=down.startYaw+(Math.atan2(my-down.cy,mx-down.cx)-down.startAng); } // rotate about its centre
|
||||
else { const [wmx,wmy]=unrotPt(mx,my); r._editCx=tf.minX+(wmx-tf.pad)/tf.sc; r._editCz=tf.minZ+(wmy-tf.pad)/tf.sc; }
|
||||
drawStore();
|
||||
});
|
||||
window.addEventListener('mouseup',async e=>{
|
||||
if(!down) return; const d=down; down=null;
|
||||
if(d.dragRack && d.moved){ const r=ST.racks.find(x=>x.id===d.dragRack);
|
||||
await post('/nav/rack/'+d.dragRack,{pos_x:+r.x.toFixed(3),pos_z:+r.z.toFixed(3)}); return; }
|
||||
const cx=r._editCx??r._cx, cz=r._editCz??r._cz, yaw=r._editYaw??r._yaw; // final centre + facing
|
||||
const p=geomToPos(r,cx,cz,yaw), deg=+(((yaw*180/Math.PI)%360+360)%360).toFixed(1);
|
||||
r.x=p.pos_x; r.z=p.pos_z; r.rot=deg; r.direction='forward'; r.attach_wall=''; // moved rack becomes FREE, facing → rotation_y
|
||||
delete r._editCx; delete r._editCz; delete r._editYaw; drawStore();
|
||||
await post('/nav/rack/'+d.dragRack,{pos_x:+p.pos_x.toFixed(3),pos_z:+p.pos_z.toFixed(3),rotation_y:deg,direction:'forward',attach_wall:''});
|
||||
return; }
|
||||
if(d.moved) return; // a non-rack drag — ignore
|
||||
// a real click → dispatch by view + tab
|
||||
const {mx,my}=cvXY(e);
|
||||
|
||||
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()
|
||||
107
webstore/dig.js
107
webstore/dig.js
@ -4,15 +4,22 @@ import * as THREE from 'three';
|
||||
// bin; scroll/drag riffles a damped "cursor" through them; each sleeve hinges forward at its
|
||||
// bottom edge as you pass it; tap the front sleeve to pull it out and inspect (price/condition/
|
||||
// add-to-cart). Procedural fwip/thunk audio — no asset files.
|
||||
// Mechanics + tuned numbers ported back from THRIFTGOD's evolved fork (docs/DIG_FLIP_HANDOVER.md).
|
||||
|
||||
const REC_W = 0.31, REC_H = 0.31, GAP = 0.013, MAXFLIP = 1.95, BACK = -0.12;
|
||||
const MAXFLIP = 1.95, BACK = -0.12;
|
||||
// sleeve [width, height, thickness] per format — records, CDs, DVDs, tapes all riffle the same
|
||||
const FMT = { lp: [0.31, 0.31, 0.0035], cd: [0.142, 0.125, 0.010], dvd: [0.136, 0.19, 0.014],
|
||||
vhs: [0.105, 0.187, 0.025], cass: [0.11, 0.07, 0.017] };
|
||||
|
||||
export function createDig(renderer, { onClose } = {}) {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0c);
|
||||
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.01, 50);
|
||||
const camera = new THREE.PerspectiveCamera(45, (innerWidth / innerHeight) || 1, 0.01, 50);
|
||||
camera.position.set(0, 0.5, 0.92);
|
||||
camera.lookAt(0, 0.12, -0.2);
|
||||
addEventListener('resize', () => { // index.html's resize handler only feeds the walk camera
|
||||
camera.aspect = (innerWidth / innerHeight) || 1; camera.updateProjectionMatrix();
|
||||
});
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
|
||||
const spot = new THREE.SpotLight(0xfff2e6, 26, 5, 0.7, 0.5, 1.2);
|
||||
@ -29,7 +36,7 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
const css = el('style'); css.textContent = `
|
||||
.dg-hint{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:rgba(0,0,0,.6);color:#eee;padding:6px 14px;border-radius:20px;font:13px system-ui;pointer-events:none;display:none}
|
||||
.dg-cur{position:fixed;left:50%;top:22px;transform:translateX(-50%);color:#fff;font:500 16px system-ui;text-shadow:0 1px 4px #000;pointer-events:none;text-align:center;max-width:80%;display:none}
|
||||
.dg-panel{position:fixed;right:6%;top:50%;transform:translateY(-50%);width:280px;background:rgba(16,16,20,.94);border:1px solid #333;border-radius:12px;padding:18px;color:#eee;font:14px system-ui;display:none}
|
||||
.dg-panel{position:fixed;right:6%;top:50%;transform:translateY(-50%);width:280px;background:rgba(16,16,20,.94);border:1px solid #333;border-radius:12px;padding:18px;color:#eee;font:14px system-ui;display:none;z-index:7}
|
||||
.dg-panel h3{margin:.1em 0;font-weight:500;color:#ff5db1}
|
||||
.dg-panel .meta{color:#aaa;font-size:13px;margin:4px 0 10px}
|
||||
.dg-panel .price{font-size:24px;font-weight:500}
|
||||
@ -63,32 +70,76 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// the crate drawn around the stack — floor/left/right/back + a LOWER front lip you flip over
|
||||
let crate = null;
|
||||
function buildCrate(maxW, maxH, depth) {
|
||||
if (crate) scene.remove(crate);
|
||||
crate = new THREE.Group();
|
||||
// ponytail: plain wood colour — no wood texture in webstore/assets; add one + RepeatWrapping ×2 if it looks flat
|
||||
const wood = new THREE.MeshStandardMaterial({ color: 0x8a6a45, roughness: 0.85 });
|
||||
const W = maxW + 0.06, H = maxH * 0.72, D = depth + 0.10, th = 0.016;
|
||||
const add = (w, h, d, x, y, z) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), wood); m.position.set(x, y, z); crate.add(m); };
|
||||
add(W, th, D, 0, -th / 2, -D / 2 + 0.05); // floor
|
||||
add(th, H, D, -W / 2, H / 2, -D / 2 + 0.05); // left
|
||||
add(th, H, D, W / 2, H / 2, -D / 2 + 0.05); // right
|
||||
add(W, H, th, 0, H / 2, -D + 0.05); // back
|
||||
add(W, H * 0.55, th, 0, H * 0.275, 0.05); // front lip (lower — you flip over it)
|
||||
scene.add(crate);
|
||||
}
|
||||
|
||||
function buildStack(records) {
|
||||
recs.forEach(r => scene.remove(r.group)); recs = [];
|
||||
let z = -0.05, maxW = 0.31, maxH = 0.31;
|
||||
records.forEach((rec, i) => {
|
||||
const grp = new THREE.Group(); grp.position.set(0, 0, -i * GAP - 0.05);
|
||||
const [w, h, th] = FMT[rec.fmt] || FMT.lp;
|
||||
maxW = Math.max(maxW, w); maxH = Math.max(maxH, h);
|
||||
const grp = new THREE.Group(); grp.position.set(0, 0, z);
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0x2b2b33, roughness: 0.75 });
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(REC_W, REC_H, 0.0035), mat);
|
||||
mesh.position.y = REC_H / 2; // hinge at group origin = bin floor
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, th), mat);
|
||||
mesh.position.y = h / 2; // hinge at group origin = bin floor
|
||||
grp.add(mesh); grp.rotation.x = BACK;
|
||||
// secondhand records don't stand to attention — every sleeve leans its own way
|
||||
grp.rotation.z = (((i * 2654435761) >>> 0) % 100 - 50) / 1800;
|
||||
grp.position.x = (((i * 40503) >>> 0) % 100 - 50) / 12000;
|
||||
scene.add(grp);
|
||||
recs.push({ group: grp, mat, rec, angle: BACK, loaded: false, homeZ: -i * GAP - 0.05 });
|
||||
recs.push({ group: grp, mat, rec, angle: BACK, loaded: false, homeZ: z });
|
||||
z -= th + 0.010; // packed by real thickness — VHS riffles chunkier than vinyl
|
||||
});
|
||||
buildCrate(maxW, maxH, -z);
|
||||
cursor = 0; vel = 0; lastFloor = -1; loadCoversNear(); updateCur();
|
||||
fillQueue(); // trickle-load EVERY cover so the crate fills in behind you
|
||||
}
|
||||
|
||||
let fillTimer = null;
|
||||
function fillQueue() {
|
||||
clearTimeout(fillTimer);
|
||||
const next = recs.find(r => !r.loaded);
|
||||
if (!next) return;
|
||||
loadCover(next);
|
||||
fillTimer = setTimeout(fillQueue, 120);
|
||||
}
|
||||
|
||||
function loadCover(r) {
|
||||
if (r.loaded) return;
|
||||
r.loaded = true;
|
||||
const url = r.rec.thumb; if (!url) return;
|
||||
loader.load(url, tx => { tx.colorSpace = THREE.SRGBColorSpace; r.mat.map = tx; r.mat.color.set(0xffffff); r.mat.needsUpdate = true; }, undefined, () => {});
|
||||
}
|
||||
|
||||
function loadCoversNear() {
|
||||
const c = Math.round(cursor);
|
||||
for (let i = Math.max(0, c - 8); i < Math.min(recs.length, c + 9); i++) {
|
||||
const r = recs[i]; if (r.loaded) continue; r.loaded = true;
|
||||
const url = r.rec.thumb; if (!url) continue;
|
||||
loader.load(url, tx => { tx.colorSpace = THREE.SRGBColorSpace; r.mat.map = tx; r.mat.color.set(0xffffff); r.mat.needsUpdate = true; }, undefined, () => {});
|
||||
}
|
||||
for (let i = Math.max(0, c - 10); i < Math.min(recs.length, c + 15); i++) loadCover(recs[i]);
|
||||
}
|
||||
|
||||
function updateCur() {
|
||||
const r = recs[Math.round(cursor)];
|
||||
curLbl.textContent = r ? `${r.rec.title || r.rec.sku}${r.rec.artist ? ' — ' + r.rec.artist : ''}` : '';
|
||||
curLbl.textContent = r ? `${r.rec.title || r.rec.sku}${r.rec.artist ? ' — ' + r.rec.artist : ''}` : 'crate\'s empty — keep browsing';
|
||||
}
|
||||
|
||||
// re-pack the survivors after a sleeve leaves the crate — no gap left behind
|
||||
function repack() {
|
||||
let z = -0.05;
|
||||
recs.forEach(x => { x.homeZ = z; x.group.position.z = z; z -= (FMT[x.rec.fmt] || FMT.lp)[2] + 0.010; });
|
||||
}
|
||||
|
||||
function pull() {
|
||||
@ -98,11 +149,26 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
panel.innerHTML = `<div class="x">✕</div><h3>${d.title || d.sku}</h3>`
|
||||
+ `<div class="meta">${d.artist || ''}</div>`
|
||||
+ `<div class="price">$${d.price ?? '—'}</div>`
|
||||
+ `<div class="meta">condition ${d.condition || '—'} · DealGod value: <i>— (key wire pending)</i></div>`
|
||||
+ `<div class="meta">condition ${d.condition || '—'}</div>`
|
||||
+ `<button>Add to cart</button>`;
|
||||
panel.style.display = 'block';
|
||||
panel.querySelector('.x').onclick = unpull;
|
||||
panel.querySelector('button').onclick = () => { curLbl.textContent = 'added: ' + (d.title || d.sku); };
|
||||
panel.querySelector('button').onclick = () => {
|
||||
// client cart, SKU-keyed — /shop/checkout re-prices server-side and rejects sold stock
|
||||
try {
|
||||
const cart = JSON.parse(localStorage.getItem('rg_cart') || '[]');
|
||||
if (!cart.some(x => x.sku === d.sku)) {
|
||||
cart.push({ sku: d.sku, release_id: d.release_id, title: d.title, artist: d.artist, price: d.price, thumb: d.thumb });
|
||||
localStorage.setItem('rg_cart', JSON.stringify(cart));
|
||||
}
|
||||
} catch (e) {}
|
||||
const i = recs.indexOf(r);
|
||||
scene.remove(r.group); recs.splice(i, 1);
|
||||
repack();
|
||||
pulled = null; panel.style.display = 'none';
|
||||
cursor = Math.min(cursor, Math.max(0, recs.length - 1));
|
||||
curLbl.textContent = 'in your cart: ' + (d.title || d.sku);
|
||||
};
|
||||
}
|
||||
|
||||
function unpull() {
|
||||
@ -116,7 +182,7 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
vel *= Math.pow(0.0008, dt); // damped momentum
|
||||
cursor += vel * dt;
|
||||
if (cursor < 0) { cursor = 0; vel = 0; }
|
||||
if (cursor > recs.length - 1) { cursor = recs.length - 1; vel = 0; }
|
||||
if (cursor > recs.length - 1) { cursor = Math.max(0, recs.length - 1); vel = 0; }
|
||||
const fl = Math.floor(cursor);
|
||||
if (fl !== lastFloor) { lastFloor = fl; blip('fwip'); loadCoversNear(); updateCur(); }
|
||||
recs.forEach((r, i) => {
|
||||
@ -127,8 +193,10 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
});
|
||||
if (pulled) {
|
||||
pulled.pullT = Math.min(1, pulled.pullT + dt * 3);
|
||||
pulled.group.position.lerp(new THREE.Vector3(0, 0.42, 0.55), Math.min(1, dt * 5));
|
||||
pulled.group.rotation.x += (0 - pulled.group.rotation.x) * Math.min(1, dt * 6);
|
||||
// arm's length on the view axis: whole cover in frame, tilted to face you, panel-side clear
|
||||
const h = (FMT[pulled.rec.fmt] || FMT.lp)[1];
|
||||
pulled.group.position.lerp(new THREE.Vector3(-0.1, 0.23 - h / 2, 0.12), Math.min(1, dt * 5));
|
||||
pulled.group.rotation.x += (-0.33 - pulled.group.rotation.x) * Math.min(1, dt * 6);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,6 +215,7 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
|
||||
async function open(crateId) {
|
||||
active = true; hint.style.display = 'block'; curLbl.style.display = 'block';
|
||||
camera.aspect = (innerWidth / innerHeight) || 1; camera.updateProjectionMatrix();
|
||||
try {
|
||||
const res = await fetch(`/virtual/crate/${crateId}/records`).then(r => r.json());
|
||||
buildStack(res.records || []);
|
||||
@ -155,8 +224,10 @@ export function createDig(renderer, { onClose } = {}) {
|
||||
|
||||
function close() {
|
||||
active = false; pulled = null;
|
||||
clearTimeout(fillTimer);
|
||||
[hint, curLbl, panel].forEach(e => e.style.display = 'none');
|
||||
recs.forEach(r => scene.remove(r.group)); recs = [];
|
||||
if (crate) { scene.remove(crate); crate = null; }
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
|
||||
@ -126,7 +126,7 @@ function slopedSide(x, ch, cd, hf, th, m) {
|
||||
// Cyclorama / infinity cove — the floor curves up into the named wall(s) via a fillet of radius R,
|
||||
// with a white floor lead-in, so there's no hard floor↔wall seam (the seamless studio look). Each
|
||||
// wall gets one swept profile: [wall down to R] → [quarter-circle fillet] → [flat floor lead-in].
|
||||
function buildCyclorama(walls, R, lead, W, D, H, mat) {
|
||||
function buildCyclorama(walls, R, lead, W, D, H, mat, extent) {
|
||||
const prof = [];
|
||||
for (let i = 0; i <= 6; i++) prof.push([0, H - (H - R) * i / 6]); // wall: u=0, v H→R
|
||||
for (let i = 1; i <= 12; i++) { const t = Math.PI + (Math.PI / 2) * i / 12; prof.push([R + R * Math.cos(t), R + R * Math.sin(t)]); } // fillet R→0
|
||||
@ -141,14 +141,18 @@ function buildCyclorama(walls, R, lead, W, D, H, mat) {
|
||||
};
|
||||
walls.forEach(wall => {
|
||||
const mp = maps[wall]; if (!mp) return;
|
||||
// east/west coves only run the cyclorama section (extent from the north wall), not the full L-shaped depth
|
||||
let s0 = -mp.len / 2, s1 = mp.len / 2;
|
||||
if ((wall === 'east' || wall === 'west') && extent > 0) { s0 = -D / 2; s1 = -D / 2 + extent; }
|
||||
const pos = [];
|
||||
[-mp.len / 2, mp.len / 2].forEach(s => prof.forEach(([u, v]) => { const p = mp.f(s, u, v); pos.push(p[0], p[1], p[2]); }));
|
||||
[s0, s1].forEach(s => prof.forEach(([u, v]) => { const p = mp.f(s, u, v); pos.push(p[0], p[1], p[2]); }));
|
||||
const idx = [];
|
||||
for (let p = 0; p < P - 1; p++) idx.push(p, P + p, p + 1, p + 1, P + p, P + p + 1);
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3));
|
||||
geo.setIndex(idx); geo.computeVertexNormals();
|
||||
grp.add(new THREE.Mesh(geo, mat));
|
||||
geo.setIndex(idx); geo.computeVertexNormals(); geo.computeBoundingSphere();
|
||||
const me = new THREE.Mesh(geo, mat); me.frustumCulled = false; // big static surface — never cull it
|
||||
grp.add(me);
|
||||
});
|
||||
return grp;
|
||||
}
|
||||
@ -255,7 +259,7 @@ function buildScene(data) {
|
||||
const R = num(sp.cyclorama_radius, 0.9);
|
||||
const cmat = material(sp.cyclorama_color || '#ffffff', '#ffffff');
|
||||
cmat.side = THREE.DoubleSide; cmat.roughness = 0.92; cmat.metalness = 0;
|
||||
roomGroup.add(buildCyclorama(cyc, R, Math.max(R + 0.3, 1.2), W, D, H, cmat));
|
||||
roomGroup.add(buildCyclorama(cyc, R, Math.max(R + 0.3, 1.2), W, D, H, cmat, num(sp.cyclorama_extent, 0)));
|
||||
}
|
||||
|
||||
// lights (data-driven + a soft ambient so it's never pitch black)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user