RECORDGOD/app/shop_routes.py
type-two 48f8579bff feat(shop): P4 wantlist — public 'request a record' page + admin queue
Storefront /wantlist form (themed, email-keyed, guest-friendly) posts to
/shop/wantlist → wantlist table (lazy DDL). Release pages deep-link to it
(prefilled; 'not in stock — request this record' when no copies). Admin gets
a Wantlist tab: pending/found/fulfilled queue with one-click status actions.
Default storefront menu → Records + Wanted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:29:00 +10:00

172 lines
9.2 KiB
Python

import json
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from .db import get_db
from .layout_routes import DEFAULTS
# PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here.
# No auth (theme + in-stock catalog aren't secret); driven by what the builder saves.
router = APIRouter(prefix="/shop", tags=["shop"])
@router.get("/config")
async def shop_config(db=Depends(get_db)):
row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first()
cfg = row[0] if row else None
if isinstance(cfg, str):
cfg = json.loads(cfg)
return cfg or DEFAULTS
@router.get("/catalog")
async def shop_catalog(q: str = Query(""), page: int = Query(1, ge=1), db=Depends(get_db)):
per = 24
where = ["i.in_stock", "i.store_id = 1"]
params = {"per": per, "off": (page - 1) * per}
if q:
where.append("(coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)")
params["q"] = f"%{q}%"
w = " AND ".join(where)
items = [dict(r) for r in (await db.execute(text(f"""
SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb,
i.price::float AS price, i.condition, i.kind
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
WHERE {w}
ORDER BY i.updated_at DESC NULLS LAST
LIMIT :per OFFSET :off"""), params)).mappings()]
cparams = {k: v for k, v in params.items() if k not in ("per", "off")}
total = (await db.execute(text(
f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}"
), cparams)).scalar()
return {"items": items, "page": page, "per": per, "total": total}
@router.get("/artist/{artist_id}")
async def shop_artist(artist_id: int, db=Depends(get_db)):
r = (await db.execute(text("SELECT id, name, realname FROM disc_artist WHERE id=:i"),
{"i": artist_id})).mappings().first()
return {"artist": dict(r) if r else {"id": artist_id, "name": f"Artist {artist_id}"}}
@router.get("/label/{label_id}")
async def shop_label(label_id: int, db=Depends(get_db)):
r = (await db.execute(text("SELECT id, name, profile FROM disc_label WHERE id=:i"),
{"i": label_id})).mappings().first()
return {"label": dict(r) if r else {"id": label_id, "name": f"Label {label_id}"}}
@router.get("/browse")
async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Query(""),
fmt: str = Query(""), artist: int | None = None, label: int | None = None,
year_min: int | None = None, year_max: int | None = None,
price_min: float | None = None, price_max: float | None = None,
sort: str = Query("new"), page: int = Query(1, ge=1), db=Depends(get_db)):
"""Faceted, release-grouped catalog — the public Browse page. One row per release with its cheapest copy."""
per = 24
where = ["i.in_stock", "i.store_id = 1", "i.release_id IS NOT NULL"]
params = {"per": per, "off": (page - 1) * per}
if q:
where.append("(coalesce(i.title, dr.title) ILIKE :q OR dr.artists_sort ILIKE :q)"); params["q"] = f"%{q}%"
if genre:
where.append("EXISTS (SELECT 1 FROM disc_release_genre g WHERE g.release_id=i.release_id AND g.genre_name=:genre)"); params["genre"] = genre
if style:
where.append("EXISTS (SELECT 1 FROM disc_release_style s WHERE s.release_id=i.release_id AND s.style_name=:style)"); params["style"] = style
if fmt:
where.append("EXISTS (SELECT 1 FROM disc_release_format f WHERE f.release_id=i.release_id AND f.name ILIKE :fmt)"); params["fmt"] = f"%{fmt}%"
if artist:
where.append("EXISTS (SELECT 1 FROM disc_release_artist ra WHERE ra.release_id=i.release_id AND ra.artist_id=:artist)"); params["artist"] = artist
if label:
where.append("EXISTS (SELECT 1 FROM disc_release_label rl WHERE rl.release_id=i.release_id AND rl.label_id=:label)"); params["label"] = label
if year_min:
where.append("dr.year >= :ymin"); params["ymin"] = year_min
if year_max:
where.append("dr.year <= :ymax"); params["ymax"] = year_max
if price_min:
where.append("i.price >= :pmin"); params["pmin"] = price_min
if price_max:
where.append("i.price <= :pmax"); params["pmax"] = price_max
w = " AND ".join(where)
order = {"new": "max(i.updated_at) DESC NULLS LAST", "price_asc": "min(i.price) ASC",
"price_desc": "min(i.price) DESC", "year": "dr.year DESC NULLS LAST",
"artist": "dr.artists_sort", "title": "min(coalesce(i.title, dr.title))"}.get(sort, "max(i.updated_at) DESC NULLS LAST")
items = [dict(r) for r in (await db.execute(text(f"""
SELECT i.release_id, coalesce(min(i.title), dr.title) AS title, dr.artists_sort AS artist,
dr.year, dr.country, min(i.price)::float AS price, count(*) AS copies,
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=i.release_id ORDER BY ra.position LIMIT 1) AS artist_id,
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre g WHERE g.release_id=i.release_id) AS genre
FROM inventory i JOIN disc_release dr ON dr.id = i.release_id
WHERE {w}
GROUP BY i.release_id, dr.title, dr.artists_sort, dr.year, dr.country
ORDER BY {order} LIMIT :per OFFSET :off"""), params)).mappings()]
cp = {k: v for k, v in params.items() if k not in ("per", "off")}
total = (await db.execute(text(
f"SELECT count(DISTINCT i.release_id) FROM inventory i JOIN disc_release dr ON dr.id=i.release_id WHERE {w}"
), cp)).scalar()
return {"items": items, "page": page, "per": per, "total": total}
@router.get("/facets")
async def shop_facets(db=Depends(get_db)):
async def top(tbl, col):
return [dict(r) for r in (await db.execute(text(f"""
SELECT x.{col} AS name, count(DISTINCT i.release_id) AS n
FROM {tbl} x JOIN inventory i ON i.release_id = x.release_id
WHERE i.in_stock AND i.store_id=1 AND x.{col} IS NOT NULL AND x.{col} <> ''
GROUP BY x.{col} ORDER BY n DESC LIMIT 24"""))).mappings()]
return {"genres": await top("disc_release_genre", "genre_name"),
"styles": await top("disc_release_style", "style_name"),
"formats": await top("disc_release_format", "name")}
@router.get("/release/{release_id}")
async def shop_release(release_id: int, db=Depends(get_db)):
"""Public release detail — metadata + tracklist + in-stock copies (with the Woo buy link)."""
dr = (await db.execute(text("""
SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.notes, dr.master_id,
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=dr.id ORDER BY ra.position LIMIT 1) AS artist_id,
(SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label,
(SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format,
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre,
(SELECT string_agg(DISTINCT style_name, ', ') FROM disc_release_style WHERE release_id=dr.id) AS style
FROM disc_release dr WHERE dr.id = :r"""), {"r": release_id})).mappings().first()
tracks = [dict(r) for r in (await db.execute(text("""
SELECT position, title, duration FROM disc_release_track WHERE release_id=:r ORDER BY sequence NULLS LAST, position
"""), {"r": release_id})).mappings()]
copies = [dict(r) for r in (await db.execute(text("""
SELECT sku, price::float AS price, condition, sleeve_cond, product_url
FROM inventory WHERE release_id=:r AND in_stock AND store_id=1 ORDER BY price
"""), {"r": release_id})).mappings()]
return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies}
class WantIn(BaseModel):
email: str
artist: str = ""
title: str = ""
name: str = ""
phone: str = ""
format: str = ""
max_price: float | None = None
delivery_preference: str = "either"
postcode: str = ""
notes: str = ""
release_id: int | None = None
@router.post("/wantlist")
async def shop_wantlist(body: WantIn, db=Depends(get_db)):
"""Public 'request a record we don't stock' intake — no auth, keyed by email (guest or known customer)."""
if "@" not in body.email or not (body.artist.strip() or body.title.strip()):
raise HTTPException(422, "email and an artist or title are required")
pref = body.delivery_preference if body.delivery_preference in ("pickup", "post", "either") else "either"
rid = (await db.execute(text("""
INSERT INTO wantlist (release_id, artist, title, name, phone, email, format, max_price,
delivery_preference, postcode, notes)
VALUES (:release_id, :artist, :title, :name, :phone, :email, :format, :max_price, :pref, :postcode, :notes)
RETURNING id"""), {**body.model_dump(), "pref": pref})).scalar()
await db.commit()
return {"ok": True, "id": rid}