- migrate_virtual.py: all 15 wp_rmp_disc_virtual_* tables → Postgres (~700 rows).
- app/virtual.py (public): GET /virtual/scene (geometry + slim front covers enriched
from discogs_full), /virtual/crate/{id}/records (digging), /virtual/locate (locate-stock).
Replaces payload.php — no WordPress bootstrap.
- webstore/index.html: walkable raw Three.js store (room/racks/crates from the data,
pointer-lock FPS, click-to-dig hook), served same-origin at /store.
- Proven: 51 racks, 346 crates, 277 covers; locate release→crate works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
4.4 KiB
Python
98 lines
4.4 KiB
Python
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import text
|
|
|
|
from .db import get_db, MirrorSession
|
|
|
|
# The virtual store API — PUBLIC read (it's the customer-facing storefront). Replaces
|
|
# WowPlatter's payload.php: same scene shape, but from Postgres instead of a full WP bootstrap.
|
|
router = APIRouter(prefix="/virtual", tags=["virtual-store"])
|
|
|
|
|
|
async def _all(db, table, where=""):
|
|
rows = await db.execute(text(f"SELECT * FROM {table} {where}"))
|
|
return [dict(r) for r in rows.mappings()]
|
|
|
|
|
|
async def _enrich(release_ids):
|
|
"""Pull title/artist/cover from the discogs_full mirror for a set of release ids."""
|
|
ids = [i for i in release_ids if i is not None]
|
|
if not ids:
|
|
return {}
|
|
try:
|
|
async with MirrorSession() as m:
|
|
rows = await m.execute(text(
|
|
"SELECT id, title, artists_sort, COALESCE(thumb_local_url, thumb) AS thumb "
|
|
"FROM release WHERE id = ANY(:ids)"), {"ids": ids})
|
|
return {r["id"]: dict(r) for r in rows.mappings()}
|
|
except Exception:
|
|
return {} # mirror down → scene still renders, just without covers/titles
|
|
|
|
|
|
def _attach(recs, meta):
|
|
for r in recs:
|
|
m = meta.get(r["release_id"], {})
|
|
r["title"], r["artist"], r["thumb"] = m.get("title"), m.get("artists_sort"), m.get("thumb")
|
|
return recs
|
|
|
|
|
|
@router.get("/scene")
|
|
async def scene(db=Depends(get_db)):
|
|
"""Everything the renderer needs at boot. Records are SLIM — one front cover per crate;
|
|
the full per-crate list is lazy-loaded on dig via /virtual/crate/{id}/records."""
|
|
spaces = await _all(db, "virtual_space", "WHERE visible='y'")
|
|
active = spaces[0] if len(spaces) == 1 else next(
|
|
(s for s in spaces if s.get("is_default") == "y"), spaces[0] if spaces else None)
|
|
|
|
recs = [dict(r) for r in (await db.execute(text("""
|
|
SELECT DISTINCT ON (crate_id) crate_id, slot_number, release_id, sku, price, condition
|
|
FROM inventory
|
|
WHERE in_stock AND crate_id IS NOT NULL AND release_id IS NOT NULL
|
|
ORDER BY crate_id, slot_number NULLS LAST"""))).mappings()]
|
|
recs = _attach(recs, await _enrich({r["release_id"] for r in recs}))
|
|
|
|
return {
|
|
"space": active, "spaces": spaces,
|
|
"racks": await _all(db, "virtual_rack", "WHERE visible='y'"),
|
|
"rack_types": await _all(db, "virtual_rack_type"),
|
|
"rack_type_levels": await _all(db, "virtual_rack_type_level"),
|
|
"crates": await _all(db, "virtual_crate", "WHERE visible='y'"),
|
|
"crate_types": await _all(db, "virtual_crate_type"),
|
|
"cameras": await _all(db, "virtual_camera"),
|
|
"lights": await _all(db, "virtual_light"),
|
|
"decals": await _all(db, "virtual_decal", "WHERE visible <> 'n'"),
|
|
"portals": await _all(db, "virtual_portal"),
|
|
"records": recs,
|
|
}
|
|
|
|
|
|
@router.get("/crate/{crate_id}/records")
|
|
async def crate_records(crate_id: int, db=Depends(get_db)):
|
|
"""Full contents of one crate — loaded when the shopper starts digging it."""
|
|
recs = [dict(r) for r in (await db.execute(text("""
|
|
SELECT release_id, slot_number, sku, price, condition
|
|
FROM inventory WHERE crate_id = :c AND in_stock AND release_id IS NOT NULL
|
|
ORDER BY slot_number NULLS LAST"""), {"c": crate_id})).mappings()]
|
|
recs = _attach(recs, await _enrich({r["release_id"] for r in recs}))
|
|
return {"ok": True, "crate_id": crate_id, "records": recs}
|
|
|
|
|
|
@router.get("/locate")
|
|
async def locate(release_id: int, db=Depends(get_db)):
|
|
"""Locate-stock: where in the physical store is this record? Returns the crate + position
|
|
so the UI can fly the camera there and flash the bin."""
|
|
row = (await db.execute(text("""
|
|
SELECT i.crate_id, i.slot_number, i.sku,
|
|
c.name, c.label_text, c.pos_x, c.pos_y, c.pos_z
|
|
FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id
|
|
WHERE i.release_id = :r AND i.in_stock AND i.crate_id IS NOT NULL
|
|
ORDER BY i.slot_number NULLS LAST LIMIT 1"""), {"r": release_id})).mappings().first()
|
|
if not row:
|
|
return {"ok": True, "located": False, "release_id": release_id}
|
|
d = dict(row)
|
|
return {
|
|
"ok": True, "located": True, "release_id": release_id,
|
|
"crate_id": d["crate_id"], "slot_number": d["slot_number"], "sku": d["sku"],
|
|
"crate": {"name": d["name"], "label_text": d["label_text"],
|
|
"pos": [float(d["pos_x"] or 0), float(d["pos_y"] or 0), float(d["pos_z"] or 0)]},
|
|
}
|