RECORDGOD/app/settings_routes.py
type-two 5351cfb9c2 feat(pos): rebuild Sales — 3 tabs, smooth checkout + tender/change, print+email receipts
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>
2026-06-23 16:29:42 +10:00

101 lines
4.6 KiB
Python

from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from . import vault
from .auth import require_admin
from .db import get_db
# ADMIN-ONLY (require_admin): API keys / connections. Staff tokens get 403 here.
# The dash POSTs credentials in; values are encrypted at rest and NEVER returned.
router = APIRouter(prefix="/settings", tags=["settings"])
# The credentials the dash knows how to collect (name → human label).
KNOWN = {
"woo_base_url": "WooCommerce store URL (https://…)",
"woo_consumer_key": "WooCommerce REST consumer key (ck_…)",
"woo_consumer_secret": "WooCommerce REST consumer secret (cs_…)",
"discogs_consumer_key": "Discogs OAuth consumer key",
"discogs_consumer_secret": "Discogs OAuth consumer secret",
"discogs_token": "Discogs personal access token",
"cloudflare_api_token": "Cloudflare API token",
"dealgod_api_key": "DealGod API key (X-Api-Key)",
"smtp_host": "Receipt email — SMTP host (e.g. mail.monsterrobot.party)",
"smtp_port": "Receipt email — SMTP port (587 STARTTLS / 465 SSL)",
"smtp_user": "Receipt email — SMTP username (e.g. shop@monsterrobot.party)",
"smtp_pass": "Receipt email — SMTP password",
"smtp_from": "Receipt email — From address (defaults to username)",
"smtp_from_name": "Receipt email — From name (e.g. Monster Robot Records)",
}
class SecretIn(BaseModel):
name: str
value: str
@router.get("/secrets")
async def list_secrets(ident=Depends(require_admin), db=Depends(get_db)):
have = {s["name"]: s["updated_at"] for s in await vault.list_secret_names(db)}
return {"ok": True, "fields": [
{"name": n, "label": label, "set": n in have, "updated_at": have.get(n)}
for n, label in KNOWN.items()
]}
@router.post("/secrets")
async def save_secret(body: SecretIn, ident=Depends(require_admin), db=Depends(get_db)):
if body.name not in KNOWN:
raise HTTPException(400, "unknown secret name")
if not body.value.strip():
raise HTTPException(400, "empty value")
try:
await vault.set_secret(db, body.name, body.value.strip())
except vault.SecretsUnconfigured as e:
raise HTTPException(503, str(e))
return {"ok": True, "name": body.name, "set": True}
@router.post("/test/{service}")
async def test_connection(service: str, ident=Depends(require_admin), db=Depends(get_db)):
"""Validate stored credentials against the live service — so 'connected' means connected."""
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
if service == "discogs":
tok = await vault.get_secret(db, "discogs_token")
if not tok:
raise HTTPException(400, "save a Discogs personal access token first")
r = await c.get("https://api.discogs.com/oauth/identity",
headers={"Authorization": f"Discogs token={tok}",
"User-Agent": "RecordGod/0.1"})
if r.status_code == 200:
return {"ok": True, "connected": True, "as": r.json().get("username")}
return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"}
if service == "woo":
base = await vault.get_secret(db, "woo_base_url")
ck = await vault.get_secret(db, "woo_consumer_key")
cs = await vault.get_secret(db, "woo_consumer_secret")
if not (base and ck and cs):
raise HTTPException(400, "save Woo store URL + consumer key + secret first")
r = await c.get(base.rstrip("/") + "/wp-json/wc/v3/orders",
params={"per_page": 1}, auth=(ck, cs))
if r.status_code == 200:
return {"ok": True, "connected": True, "as": urlparse(base).netloc}
return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"}
if service == "dealgod":
key = await vault.get_secret(db, "dealgod_api_key")
if not key:
raise HTTPException(400, "save a DealGod API key first")
r = await c.get("https://api.dealgod.pro/api/me", headers={"X-Api-Key": key})
if r.status_code == 200:
j = r.json()
return {"ok": True, "connected": True, "as": f"{j.get('user')} · {j.get('plan')}"}
if r.status_code == 404:
return {"ok": True, "connected": False, "detail": "DealGod /api/me not deployed yet"}
return {"ok": True, "connected": False, "detail": f"HTTP {r.status_code}"}
raise HTTPException(400, "unknown service")