Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.
What is here:
storefront/ the Next.js app
backend/ ONLY the custom Spree code. Spree itself is a prebuilt image
that mounts no app code, so every file here needs an explicit
volume entry in docker-compose.yml or it is silently ignored.
catalogue/ the scripts that loaded and maintain the products
docs/ manifest, mock registry, napkins
Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.
The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reprice all products from the source MYR catalogue.
|
||||
|
||||
Replaces the prices written by fix_prices.py, which multiplied by 4.8 instead of
|
||||
dividing — putting every product on the store at ~23x its real price.
|
||||
|
||||
Writes BOTH currency rows the store supports:
|
||||
ZAR (store default) = price_myr * MYR_TO_ZAR
|
||||
USD (still in supported_currencies) = price_myr * MYR_TO_USD
|
||||
|
||||
The USD row is corrected in the same pass on purpose: leaving the old 23x rows
|
||||
behind means anyone switching the store currency to USD sees the broken prices
|
||||
again, with nothing to warn them.
|
||||
|
||||
Rates are fetched live and printed. No hardcoded rate, no fallback rate — if the
|
||||
fetch fails this exits rather than pricing a live store off a guess.
|
||||
|
||||
Usage:
|
||||
reprice_zar.py --dry-run # show the changes, write nothing (do this first)
|
||||
reprice_zar.py # apply, then read back and check every row
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _spree_admin_key() -> str:
|
||||
"""The Spree admin API key. Required — never defaulted."""
|
||||
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"SPREE_ADMIN_KEY is not set.\n"
|
||||
" export SPREE_ADMIN_KEY=<the admin API key>\n"
|
||||
"It is deliberately not stored in this file — see README.md."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
API = "https://api.jux-lab.com/api/v3/admin"
|
||||
H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"}
|
||||
PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products")
|
||||
DRY = "--dry-run" in sys.argv
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"\nFATAL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- rates -------------------------------------------------------------------
|
||||
def get_rates():
|
||||
"""Live MYR base rates. Loud failure beats a stale constant — see fix_prices.py."""
|
||||
try:
|
||||
d = requests.get("https://open.er-api.com/v6/latest/MYR", timeout=20).json()
|
||||
except Exception as e:
|
||||
fatal(f"rate fetch failed: {e}")
|
||||
r = d.get("rates") or {}
|
||||
zar, usd = r.get("ZAR"), r.get("USD")
|
||||
if not zar or not usd:
|
||||
fatal(f"rate response missing ZAR/USD: {d}")
|
||||
print(f"rates 1 MYR = {zar} ZAR = {usd} USD ({d.get('time_last_update_utc')})")
|
||||
return float(zar), float(usd)
|
||||
|
||||
|
||||
# --- source catalogue --------------------------------------------------------
|
||||
def load_source():
|
||||
prices = {}
|
||||
for folder in sorted(PRODUCTS.iterdir()):
|
||||
info = folder / "info.json"
|
||||
if not info.exists():
|
||||
continue
|
||||
myr = json.loads(info.read_text()).get("price_myr")
|
||||
if myr is None:
|
||||
fatal(f"{folder.name}/info.json has no price_myr")
|
||||
prices[folder.name] = float(myr)
|
||||
if not prices:
|
||||
fatal(f"no source prices found under {PRODUCTS}")
|
||||
print(f"source {len(prices)} products, MYR {min(prices.values()):.2f}-{max(prices.values()):.2f}")
|
||||
return prices
|
||||
|
||||
|
||||
def fetch_products():
|
||||
items, page = [], 1
|
||||
while True:
|
||||
d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json()
|
||||
items += d.get("data", [])
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
break
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def main():
|
||||
zar_rate, usd_rate = get_rates()
|
||||
src = load_source()
|
||||
items = fetch_products()
|
||||
print(f"store {len(items)} products")
|
||||
|
||||
planned, missing = {}, []
|
||||
for p in items:
|
||||
slug = p["slug"]
|
||||
myr = src.get(slug)
|
||||
if myr is None:
|
||||
missing.append(slug)
|
||||
continue
|
||||
planned[p["id"]] = {
|
||||
"slug": slug,
|
||||
"myr": myr,
|
||||
"ZAR": round(myr * zar_rate, 2),
|
||||
"USD": round(myr * usd_rate, 2),
|
||||
}
|
||||
|
||||
if missing:
|
||||
# Not fatal: these keep their current prices. But they must be named, not
|
||||
# silently skipped — a product left at 23x is the whole bug again.
|
||||
print(f"\n{len(missing)} products have NO source price and will be LEFT UNCHANGED:")
|
||||
for s in missing:
|
||||
print(f" ! {s}")
|
||||
|
||||
sample = list(planned.values())[:5]
|
||||
print(f"\n{len(planned)} to reprice. Sample:")
|
||||
for s in sample:
|
||||
print(f" MYR {s['myr']:>7.2f} -> ZAR {s['ZAR']:>8.2f} / USD {s['USD']:>7.2f} {s['slug'][:44]}")
|
||||
zars = [v["ZAR"] for v in planned.values()]
|
||||
print(f" new ZAR range: {min(zars):.2f} - {max(zars):.2f}")
|
||||
|
||||
if DRY:
|
||||
print("\n--dry-run: nothing written.")
|
||||
return
|
||||
|
||||
# --- apply ---------------------------------------------------------------
|
||||
print()
|
||||
ok = fail = 0
|
||||
for n, (pid, s) in enumerate(planned.items(), 1):
|
||||
body = {"prices": [
|
||||
{"amount": s["ZAR"], "currency": "ZAR"},
|
||||
{"amount": s["USD"], "currency": "USD"},
|
||||
]}
|
||||
try:
|
||||
r = requests.patch(f"{API}/products/{pid}", headers=H, json=body, timeout=60).json()
|
||||
except Exception as e:
|
||||
fail += 1
|
||||
print(f" ERROR {s['slug'][:40]}: {e}")
|
||||
continue
|
||||
if "error" in r:
|
||||
fail += 1
|
||||
print(f" FAIL {s['slug'][:40]}: {r['error'].get('message')}")
|
||||
else:
|
||||
ok += 1
|
||||
if n % 40 == 0:
|
||||
print(f" {n}/{len(planned)}")
|
||||
time.sleep(0.12)
|
||||
print(f"patched ok={ok} fail={fail}")
|
||||
|
||||
# --- read back -----------------------------------------------------------
|
||||
# The store returns 200 for a price write that never created a row in the
|
||||
# store currency. Trusting that 200 is what hid 130 products for an hour.
|
||||
print("\nreading back...")
|
||||
time.sleep(2)
|
||||
bad = []
|
||||
for p in fetch_products():
|
||||
want = planned.get(p["id"])
|
||||
if not want:
|
||||
continue
|
||||
a = p.get("attributes", p)
|
||||
got = a.get("price")
|
||||
cur = a.get("currency")
|
||||
if got is None or abs(float(got) - want["ZAR"]) > 0.01 or cur != "ZAR":
|
||||
bad.append((want["slug"], want["ZAR"], got, cur))
|
||||
if bad:
|
||||
print(f"\n{len(bad)} products did NOT take the ZAR price:")
|
||||
for slug, want, got, cur in bad[:15]:
|
||||
print(f" ! {slug[:40]} want ZAR {want} got {got} {cur}")
|
||||
sys.exit(1)
|
||||
print(f"check ok: all {len(planned)} products report the expected ZAR price.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user