7 tabs → Register / History / Settings. Register: search/scan + cart + checkout flow
(cash tender→change, split tenders, layby deposit, notes) → completes → receipt auto-pops.
Receipts now server-rendered (app/receipts.py, one template) with per-item discounts, GST
breakdown, split lines, tendered/change, store header/footer; Print opens a clean window
(no visibility hack); Email via app/mailer.py (stdlib SMTP, creds in vault, admin-only) with
a Settings 'send test' button. History merges past sales + laybys (reprint/collect). Settings
holds currency/GST/discount + receipt header/footer + promotions. sales gains amount_tendered
+ notes; SaleIn carries tendered/notes; /sales/{id}/receipt + /{id}/email + /email-test added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
8.6 KiB
Python
131 lines
8.6 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())",
|
|
# 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",
|
|
"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"):
|
|
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")
|