RECORDGOD/app/main.py
type-two d489e8807d feat(buylist): wishlist scarcity ranking via DealGod /api/supply
DealGod shipped POST /api/supply (store_count + discogs_seller_count per release). RecordGod half:
PRICEGOD scrapes the RareWaves wishlist (www.rarewaves.com/pages/wishlist) → /products/<ean>- barcodes +
colour-from-slug → POST /admin/wishlist/scarcity → resolve barcodes → batch DealGod /api/supply → upsert
buylist (store_count/au_copies/lowest_au/discogs_seller_count/discogs_lowest + already_stocked). New 🎯 Buy
list admin view ranks rarest-first (store_count asc, sellers asc), flags colour vinyl + already-stocked.
Endpoints /admin/{wishlist/scarcity, buylist, buylist/clear}; _dealgod_supply batches ≤200. Verified e2e on
6 real wishlist items: ranked 0-store rarest first, AU prices live, sellers backfill per DealGod's pin sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:07:48 +10:00

165 lines
11 KiB
Python

import os
import pathlib
# Load .env FIRST so auth/db/vault see their keys at import time.
_ENV = pathlib.Path(__file__).resolve().parent.parent / ".env"
if _ENV.exists():
for _ln in _ENV.read_text().splitlines():
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip())
from fastapi import FastAPI # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
from fastapi.responses import FileResponse # noqa: E402
from . import __version__ # noqa: E402
from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402
from .virtual import router as virtual_router # noqa: E402
from .settings_routes import router as settings_router # noqa: E402
from .admin_routes import router as admin_router # noqa: E402
from .layout_routes import router as layout_router # noqa: E402
from .shop_routes import router as shop_router # noqa: E402
from .sales_routes import router as sales_router # noqa: E402
from .navigator_routes import router as navigator_router # noqa: E402
from .collections_routes import router as collections_router # noqa: E402
from .disc_images import router as disc_images_router # noqa: E402
from .intake_routes import router as intake_router # noqa: E402
from .auth_routes import router as auth_router # noqa: E402
from .db import engine # noqa: E402
from sqlalchemy import text as _sqltext # noqa: E402
app = FastAPI(title="RECORDGOD", version=__version__)
app.include_router(wowplatter_v1_router)
app.include_router(virtual_router)
app.include_router(settings_router)
app.include_router(admin_router)
app.include_router(layout_router)
app.include_router(shop_router)
app.include_router(sales_router)
app.include_router(navigator_router)
app.include_router(collections_router)
app.include_router(disc_images_router)
app.include_router(intake_router)
app.include_router(auth_router)
_STARTUP_DDL = [
"CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())",
# staff accounts — each has a bearer token + role (admin sees API keys/connections, staff don't)
"CREATE TABLE IF NOT EXISTS staff (id bigserial PRIMARY KEY, name text NOT NULL, token text UNIQUE NOT NULL, role text NOT NULL DEFAULT 'staff', active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), last_seen timestamptz)",
# time clock — a shift = clock_in..clock_out; the partial unique index caps it at one open shift per staff
"CREATE TABLE IF NOT EXISTS staff_shift (id bigserial PRIMARY KEY, staff_id bigint NOT NULL, clock_in timestamptz NOT NULL DEFAULT now(), clock_out timestamptz, created_at timestamptz NOT NULL DEFAULT now())",
"CREATE UNIQUE INDEX IF NOT EXISTS staff_shift_one_open ON staff_shift (staff_id) WHERE clock_out IS NULL",
# public 'request a record we don't stock' intake (storefront wantlist; email-keyed, guest or customer)
"CREATE TABLE IF NOT EXISTS wantlist (id bigserial PRIMARY KEY, release_id int, artist text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '', name text, phone text, email text NOT NULL, format text, max_price numeric(10,2), delivery_preference text DEFAULT 'either', postcode text, notes text, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now(), actioned_at timestamptz)",
"CREATE INDEX IF NOT EXISTS idx_wantlist_status ON wantlist (status, created_at DESC)",
# POS reference tables (populated by migrate.py; created here so prod has them on deploy)
"CREATE TABLE IF NOT EXISTS customer (id bigint PRIMARY KEY, wp_user_id bigint, first_name text NOT NULL DEFAULT '', last_name text DEFAULT '', email text, phone text, address text, is_guest boolean NOT NULL DEFAULT false, created_at timestamptz DEFAULT now())",
"CREATE SEQUENCE IF NOT EXISTS customer_id_seq",
"ALTER TABLE customer ALTER COLUMN id SET DEFAULT nextval('customer_id_seq')",
"SELECT setval('customer_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM customer)))",
"CREATE TABLE IF NOT EXISTS sales_discount (id bigint PRIMARY KEY, name text NOT NULL, percentage numeric(5,2) NOT NULL DEFAULT 0, is_active boolean NOT NULL DEFAULT true, manual_active boolean NOT NULL DEFAULT false, discount_type text DEFAULT 'manual', description text, bubble_text text, bubble_color text, start_at timestamptz, end_at timestamptz, created_at timestamptz DEFAULT now())",
"CREATE TABLE IF NOT EXISTS sales_setting (setting_key text PRIMARY KEY, setting_value text, is_active boolean NOT NULL DEFAULT true, updated_at timestamptz DEFAULT now())",
# a Guest customer always available at the counter
"INSERT INTO customer (id, first_name, last_name, email, is_guest) VALUES (0, 'Guest', 'Sale', NULL, true) ON CONFLICT (id) DO NOTHING",
"ALTER TABLE customer ADD COLUMN IF NOT EXISTS mailing_optin boolean DEFAULT false",
"ALTER TABLE customer ADD COLUMN IF NOT EXISTS notes text",
# disc_cache enrichment for the collection fallback locator (backfilled from discogs metadata separately)
"ALTER TABLE disc_cache ADD COLUMN IF NOT EXISTS year int",
"ALTER TABLE disc_cache ADD COLUMN IF NOT EXISTS genre_ids int[]",
"ALTER TABLE disc_cache ADD COLUMN IF NOT EXISTS style_ids int[]",
# POS fields on the migrated sales tables
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS subtotal numeric(10,2)",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS payment_status text DEFAULT 'paid'",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_paid numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS hold_expires_at timestamptz",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS split_payments jsonb",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_tendered numeric(10,2)",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS notes text",
# audio: lazily-fetched per-track Apple previews + YouTube dead-link cache (re-added here in case a sync ran)
"ALTER TABLE disc_release_track ADD COLUMN IF NOT EXISTS apple_preview text",
"ALTER TABLE disc_release_video ADD COLUMN IF NOT EXISTS dead boolean",
"ALTER TABLE disc_release_video ADD COLUMN IF NOT EXISTS checked_at timestamptz",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)",
# auto-id for new POS rows (migrated tables came without sequences)
"CREATE SEQUENCE IF NOT EXISTS sales_id_seq",
"ALTER TABLE sales ALTER COLUMN id SET DEFAULT nextval('sales_id_seq')",
"SELECT setval('sales_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM sales)))",
"CREATE SEQUENCE IF NOT EXISTS sale_items_id_seq",
"ALTER TABLE sale_items ALTER COLUMN id SET DEFAULT nextval('sale_items_id_seq')",
"SELECT setval('sale_items_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM sale_items)))",
# fast fuzzy staff search — pg_trgm GIN indexes (typo-tolerant, multi-word, index-accelerated;
# replaces the ILIKE '%..%' seq scans). word_similarity threshold pinned on the DB (0.3 = good recall).
"CREATE EXTENSION IF NOT EXISTS pg_trgm",
"ALTER DATABASE recordgod SET pg_trgm.word_similarity_threshold = 0.45", # recall vs speed sweet spot (~140ms)
"CREATE INDEX IF NOT EXISTS disc_release_search_trgm ON disc_release USING gin (search_text gin_trgm_ops)",
"CREATE INDEX IF NOT EXISTS inventory_title_trgm ON inventory USING gin (title gin_trgm_ops)",
"CREATE INDEX IF NOT EXISTS disc_release_format_release_id_idx ON disc_release_format(release_id)",
# staff sign-in (email + password) + details for the timesheet
"ALTER TABLE staff ADD COLUMN IF NOT EXISTS email text",
"ALTER TABLE staff ADD COLUMN IF NOT EXISTS phone text",
"ALTER TABLE staff ADD COLUMN IF NOT EXISTS pay_rate numeric(10,2)",
"ALTER TABLE staff ADD COLUMN IF NOT EXISTS password_hash text",
"CREATE UNIQUE INDEX IF NOT EXISTS staff_email_uniq ON staff (lower(email)) WHERE email IS NOT NULL",
# cost tracking (per-copy buy price from distro orders) + new/used split
"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'
# 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'",
# wishlist buy-list: scarcity-ranked want-to-buy (store_count + discogs sellers via DealGod /api/supply)
"CREATE TABLE IF NOT EXISTS buylist (store_id int NOT NULL, release_id bigint NOT NULL, barcode text, "
"title text, colour text, source text, store_count int, au_copies int, lowest_au numeric(10,2), "
"median_au numeric(10,2), discogs_seller_count int, discogs_lowest numeric(10,2), "
"already_stocked boolean DEFAULT false, updated_at timestamptz NOT NULL DEFAULT now(), "
"PRIMARY KEY (store_id, release_id))",
]
@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))
@app.get("/healthz")
async def healthz():
return {"ok": True, "service": "recordgod", "version": __version__}
_ROOT = pathlib.Path(__file__).resolve().parent.parent
# Pretty URLs: /builder /shop /admin /dash → the .html pages (registered before the "/" mount wins).
# no-cache so a deploy shows immediately — the browser revalidates the HTML each load (no stale dark pages).
def _page(filename):
async def _serve():
return FileResponse(_ROOT / "site" / filename,
headers={"Cache-Control": "no-cache, must-revalidate"})
return _serve
for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records", "wantlist", "kiosk", "login"):
app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False)
# public storefront release detail — release.html reads the id from the path
app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"], include_in_schema=False)
# 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)
# Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.
if (_ROOT / "webstore").is_dir():
app.mount("/store", StaticFiles(directory=str(_ROOT / "webstore"), html=True), name="store")
if (_ROOT / "site").is_dir():
app.mount("/", StaticFiles(directory=str(_ROOT / "site"), html=True), name="site")