← back to Schumacher Restore

restore.py

92 lines

#!/usr/bin/env python3
"""
Schumacher archived -> active restore tool.  PREP / DRY-RUN BY DEFAULT.

Re-applies the staged product image (schumacher_catalog S3 url) and un-archives each
archived Schumacher product that has a staged image + cost.  Writes a restore-map FIRST
so every action is one-command reversible (restore_reverse.py).

SAFE BY CONSTRUCTION:
  * DRY_RUN is the default. It prints the planned actions and writes the restore-map,
    but performs ZERO writes to Shopify.
  * Live writes require BOTH:  --apply  AND  env SCHU_RESTORE_CONFIRM=YES
  * --canary N limits to the first N (do 20 first, verify, then widen).
  * ~0.5s paced; image POST then status->active per product.

Usage:
  python3 restore.py                       # DRY-RUN, whole work-list (no writes)
  python3 restore.py --canary 20           # DRY-RUN of the first 20
  SCHU_RESTORE_CONFIRM=YES python3 restore.py --apply --canary 20   # LIVE canary (Steve-gated)

Owner: vp-dw-commerce.  Do not run --apply without Steve's explicit go + naming decision.
"""
import os, sys, json, time, urllib.request, urllib.error

HERE = os.path.dirname(os.path.abspath(__file__))
WORKLIST = os.path.join(HERE, "data", "restore-worklist.json")
RESTOREMAP = os.path.join(HERE, "data", "restore-map.json")
SHOP = "designer-laboratory-sandbox.myshopify.com"
API = "2024-10"

def token():
    for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if line.startswith("SHOPIFY_ADMIN_TOKEN="):
            return line.split("=", 1)[1].strip()
    sys.exit("no SHOPIFY_ADMIN_TOKEN")

def api(method, path, tok, body=None):
    req = urllib.request.Request(f"https://{SHOP}/admin/api/{API}/{path}",
                                 data=json.dumps(body).encode() if body else None,
                                 method=method,
                                 headers={"X-Shopify-Access-Token": tok,
                                          "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=40) as r:
        return json.load(r)

def main():
    apply = "--apply" in sys.argv
    canary = None
    if "--canary" in sys.argv:
        canary = int(sys.argv[sys.argv.index("--canary") + 1])
    confirmed = os.environ.get("SCHU_RESTORE_CONFIRM") == "YES"
    live = apply and confirmed
    if apply and not confirmed:
        print("REFUSING --apply without SCHU_RESTORE_CONFIRM=YES. Running DRY-RUN instead.\n")

    work = json.load(open(WORKLIST))
    if canary:
        work = work[:canary]

    # restore-map written up front (reversibility), regardless of dry/live
    json.dump([{"num": w["num"], "prev_status": w["prev_status"]} for w in work],
              open(RESTOREMAP, "w"), indent=0)
    mode = "LIVE-APPLY" if live else "DRY-RUN"
    print(f"=== Schumacher restore [{mode}] — {len(work)} products "
          f"(restore-map: {RESTOREMAP}) ===\n")

    tok = token() if live else None
    done = err = 0
    for i, w in enumerate(work, 1):
        retail = round(float(w["staged_cost"]) / 0.65 / 0.85, 2)  # DW formula, for display only
        line = (f"[{i}/{len(work)}] product {w['num']} mfr={w['mfr_sku']} "
                f"cost=${w['staged_cost']} retail~${retail} img={w['staged_image']}")
        if not live:
            print("WOULD: re-image + un-archive |", line)
            continue
        try:
            api("POST", f"products/{w['num']}/images.json", tok,
                {"image": {"src": w["staged_image"]}})
            api("PUT", f"products/{w['num']}.json", tok,
                {"product": {"id": int(w["num"]), "status": "active"}})
            done += 1; print("OK   :", line)
        except urllib.error.HTTPError as e:
            err += 1; print(f"FAIL : {line} -> HTTP {e.code}")
        time.sleep(0.5)
    if live:
        print(f"\napplied={done} failed={err}. Reverse with restore_reverse.py")
    else:
        print(f"\nDRY-RUN complete. {len(work)} planned. Nothing written to Shopify.")

if __name__ == "__main__":
    main()