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,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace product 129's square thumbnail with the reframed 762x1100 version.
|
||||
|
||||
The source was a 2400x2400 square. The storefront grid renders at aspect-3/4
|
||||
with object-cover, so a 1:1 image loses 12.5% off each side — enough to cut the
|
||||
heel and the toe off the shoe. Every other shoe in the catalogue is 762x1100
|
||||
with the subject at ~93% width, so this reframes 129 to match its siblings.
|
||||
|
||||
Uploads the new asset at position 1, verifies it is really there, then removes
|
||||
the square original. --apply is required; the default is a dry run.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
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
|
||||
|
||||
|
||||
SPREE_API = "https://api.jux-lab.com/api/v3/admin"
|
||||
SPREE_KEY = _spree_admin_key()
|
||||
SLUG = "129-adidas-lifestyle-barreda-shoes-men-white-jp5928"
|
||||
NEW_IMAGE = pathlib.Path(
|
||||
"/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products/"
|
||||
"129-adidas-lifestyle-barreda-shoes-men-white-jp5928/image-1-reframed.jpg"
|
||||
)
|
||||
|
||||
|
||||
def fatal(msg):
|
||||
print(f"FATAL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def multipart(fields, file_field, filename, blob, content_type):
|
||||
boundary = uuid.uuid4().hex
|
||||
parts = []
|
||||
for k, v in fields.items():
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
|
||||
)
|
||||
parts.append(
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; '
|
||||
f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode()
|
||||
+ blob
|
||||
+ b"\r\n"
|
||||
)
|
||||
parts.append(f"--{boundary}--\r\n".encode())
|
||||
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def api(path, method="GET", data=None, ctype=None):
|
||||
headers = {"X-Spree-Api-Key": SPREE_KEY}
|
||||
if ctype:
|
||||
headers["Content-Type"] = ctype
|
||||
req = urllib.request.Request(
|
||||
f"{SPREE_API}{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
fatal(f"{method} {path} -> HTTP {e.code}: {e.read().decode()[:400]}")
|
||||
|
||||
|
||||
# This Spree build serves flat objects, not JSON:API — the fields are on the
|
||||
# object itself, not under "attributes" — and it pages on `limit`, ignoring
|
||||
# `per_page` entirely.
|
||||
def find_product(slug):
|
||||
page = 1
|
||||
while True:
|
||||
_, d = api(f"/products?limit=100&page={page}")
|
||||
for p in d.get("data", []):
|
||||
if p.get("slug") == slug:
|
||||
return p
|
||||
if page >= d.get("meta", {}).get("pages", 1):
|
||||
return None
|
||||
page += 1
|
||||
|
||||
|
||||
def media_list(pid):
|
||||
_, d = api(f"/products/{pid}/media?limit=100")
|
||||
return d.get("data", [])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--apply", action="store_true", help="actually write to Spree")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not NEW_IMAGE.exists():
|
||||
fatal(f"{NEW_IMAGE} missing")
|
||||
|
||||
p = find_product(SLUG)
|
||||
if not p:
|
||||
fatal(f"product {SLUG} not found")
|
||||
pid = p["id"]
|
||||
print(f"product {pid} {p.get('name')}")
|
||||
|
||||
before = media_list(pid)
|
||||
print(f"media before: {len(before)}")
|
||||
for m in before:
|
||||
print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}")
|
||||
|
||||
if not args.apply:
|
||||
print("\nDRY RUN — re-run with --apply to upload and remove the square original.")
|
||||
return
|
||||
|
||||
old_ids = [m["id"] for m in before]
|
||||
|
||||
body, ctype = multipart(
|
||||
{"type": "Spree::Image", "position": "1"},
|
||||
"attachment",
|
||||
NEW_IMAGE.name,
|
||||
NEW_IMAGE.read_bytes(),
|
||||
"image/jpeg",
|
||||
)
|
||||
status, resp = api(f"/products/{pid}/media", "POST", body, ctype)
|
||||
new_id = (resp.get("data") or resp).get("id")
|
||||
print(f"\nupload -> HTTP {status}, new asset id={new_id}")
|
||||
if status == 202 or not new_id:
|
||||
fatal("Spree answered without a serialized asset — cannot confirm the image landed")
|
||||
|
||||
# Re-read rather than trusting the create response: media#create has a path
|
||||
# that enqueues a background job and answers 202 with no body.
|
||||
after = media_list(pid)
|
||||
if new_id not in [m["id"] for m in after]:
|
||||
fatal(f"asset {new_id} is not in the product media after upload")
|
||||
print(f"verified: asset {new_id} present, {len(after)} media on product")
|
||||
|
||||
for oid in old_ids:
|
||||
st, _ = api(f"/products/{pid}/media/{oid}", "DELETE")
|
||||
print(f"removed old asset {oid} -> HTTP {st}")
|
||||
|
||||
final = media_list(pid)
|
||||
print(f"\nmedia after: {len(final)}")
|
||||
for m in final:
|
||||
print(f" id={m['id']} position={m.get('position')} {str(m.get('original_url',''))[:80]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user