Batch-committing the in-flight app work (all modules compile clean): - app/payments.py, app/packs.py, app/discogs_mp.py, app/dealgod.py (new) - admin_routes, main, navigator/sales/shop routes, site/search.html - customer_woo_sync + disc_sync tweaks; test_dealgod_supply, test_startup_ddl Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
215 lines
9.8 KiB
Python
215 lines
9.8 KiB
Python
"""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()
|