RECORDGOD/app/main.py
type-two db1788bfe5 feat(app): payments/packs/discogs-mp/dealgod modules + admin, shop, sales route work
Batch-committing the in-flight app work (all modules compile clean):
- app/payments.py, app/packs.py, app/discogs_mp.py, app/dealgod.py (new)
- admin_routes, main, navigator/sales/shop routes, site/search.html
- customer_woo_sync + disc_sync tweaks; test_dealgod_supply, test_startup_ddl

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:07:28 +10:00

279 lines
17 KiB
Python

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():
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, Request # noqa: E402
from fastapi.staticfiles import StaticFiles # 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
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'
"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'",
# 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))",
# 3D store: cyclorama / infinity-cove walls (comma list of north/south/east/west) + fillet radius
"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 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")
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
# 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)
# "/" 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():
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")