records.html reads /artist|label|genre|style/{val} from the path, sets the
filter + an entity header (artist/label name via /shop/{kind}/{id}). /shop/browse
gains artist/label EXISTS filters + primary artist_id; release page links
artist→/artist/{id} and genre/style pills→/{kind}/{name}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
7.1 KiB
Python
121 lines
7.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 .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)
|
|
|
|
|
|
_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())",
|
|
# 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 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"):
|
|
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")
|