RECORDGOD/app/main.py
type-two 88172924eb feat(intake): three-source intake — internal lookup + Discogs collection + Google Sheet
Replaces the bare release-id box with a tabbed Intake that all feeds one _stage() core:
- Find & stage: search the local Discogs mirror (instant) with a Discogs-API fallback for new
  titles; pick a release → condition/price → stage. Cards show cover/artist/year/label/format.
- Discogs collection: lists the OAuth token's own folders (mrp3, 152 with stock) → page through
  releases, reads media/sleeve/price from the collection's custom fields, stage-all-on-page.
- Google Sheet: paste the share URL → public CSV export (no Google creds) → auto-detect columns
  (release id/media/sleeve/price/comment/timestamp) → preview → bulk import. SKU from the
  Google-Form timestamp (YYYYMMDDHHMMSS), matching migrated stock.

Staging enriches title/artist/cover on demand: a disc_cache miss fetches the release from the
Discogs API and grows the local mirror (disc_cache + disc_release + artist + label), so internal
search + storefront get richer with every intake. Verified end-to-end on the VPS (incl. enrich of
a not-in-cache release) + sheet-parser self-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:05:46 +10:00

137 lines
9.1 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 .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)
_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)))",
]
@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"):
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")