← back to Mr Image Repair

archive_discontinued.py

99 lines

#!/usr/bin/env python3
"""
TK-11638 follow-up — GATED archive of 3 DISCONTINUED Maya Romanoff River Bed colorways.
HARD-GATED: DRY-RUN by default. Requires --apply AND env CONFIRM_MR_ARCHIVE=1 to
touch Shopify (customer-facing live store designer-laboratory-sandbox).

These 3 are: (a) discontinued — the colorway is gone from the live MR River Bed line
(replaced by Glacier/Gold Rush/Silver Rush), (b) unbuyable — sample-only, no sellable
roll variant, and (c) still logo-hero with no obtainable real image. Per the DW rule
"discontinued = ARCHIVE candidate" they are archived (status ACTIVE -> ARCHIVED).

Archive is REVERSIBLE: the product record is retained and can be un-archived
(status -> active). Journal records prior status per product for a clean revert.

Reads: data/mr_unresolved.tsv (rows where bucket == disco-colorway)
Writes rollback journal: data/mr_archive_journal.jsonl (prior status per product).
"""
import csv, os, sys, json, time, urllib.request, urllib.error

HERE = os.path.dirname(os.path.abspath(__file__))
SRC  = os.path.join(HERE, "data", "mr_unresolved.tsv")
JRN  = os.path.join(HERE, "data", "mr_archive_journal.jsonl")
SHOP = "designer-laboratory-sandbox.myshopify.com"
API  = "2024-10"
APPLY = "--apply" in sys.argv

def token():
    for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        for k in ("SHOPIFY_ADMIN_TOKEN", "SHOPIFY_FULL_ACCESS_TOKEN"):
            if line.startswith(k + "="):
                return line.split("=", 1)[1].strip().strip('"').strip("'")
    raise SystemExit("no SHOPIFY token")
TOK = token()

def api(method, path, body=None, _tries=4):
    url = f"https://{SHOP}/admin/api/{API}/{path}"
    data = json.dumps(body).encode() if body is not None else None
    for attempt in range(1, _tries + 1):
        req = urllib.request.Request(url, data=data, method=method,
            headers={"X-Shopify-Access-Token": TOK, "Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                return json.loads(r.read())
        except urllib.error.HTTPError as e:
            if e.code in (429, 500, 502, 503, 504) and attempt < _tries:
                time.sleep(float(e.headers.get("Retry-After", attempt)) or attempt); continue
            raise

def product_for_handle(handle):
    d = api("GET", f"products.json?handle={handle}&fields=id,title,status,handle,variants")
    ps = d.get("products", [])
    return ps[0] if ps else None

def main():
    if APPLY and os.environ.get("CONFIRM_MR_ARCHIVE") != "1":
        print("REFUSING: --apply requires env CONFIRM_MR_ARCHIVE=1 (Steve-gated). Aborting.")
        sys.exit(2)
    rows = [r for r in csv.DictReader(open(SRC), delimiter="\t") if r.get("bucket") == "disco-colorway"]
    print(f"mode={'APPLY' if APPLY else 'DRY-RUN'} rows={len(rows)} store={SHOP}")
    jf = open(JRN, "a") if APPLY else None
    ok = err = skipped = 0
    for i, r in enumerate(rows, 1):
        dw, handle, title = r["dw_sku"], r["handle"], r["our_title"]
        try:
            prod = product_for_handle(handle)
            if not prod:
                err += 1; print(f"[{i}] ERR {dw} handle={handle} -> not found"); continue
            pid, st = prod["id"], prod["status"]
            vs = prod.get("variants", [])
            sellable = [v for v in vs if (v.get("title") or "").lower() != "sample"]
            # SAFETY: only archive if it is genuinely unbuyable (no non-sample sellable variant).
            if sellable:
                skipped += 1
                print(f"[{i}] SKIP {dw} pid={pid} has a sellable variant ({len(sellable)}) -> NOT archiving")
                continue
            if st == "archived":
                skipped += 1; print(f"[{i}] SKIP {dw} pid={pid} already archived"); continue
            if not APPLY:
                print(f"[{i}] DRY {dw} pid={pid} status={st} -> ARCHIVED  ({title}) [sample-only, discontinued]")
                ok += 1; continue
            jf.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "dw_sku": dw,
                "product_id": pid, "old_status": st, "rollback": f"PUT products/{pid}.json status=active"}) + "\n")
            jf.flush()
            api("PUT", f"products/{pid}.json", {"product": {"id": pid, "status": "archived"}})
            time.sleep(0.4)
            chk = api("GET", f"products/{pid}.json?fields=id,status").get("product", {}).get("status")
            if chk == "archived":
                ok += 1; print(f"[{i}] OK  {dw} pid={pid} now ARCHIVED")
            else:
                err += 1; print(f"[{i}] WARN {dw} pid={pid} status={chk} (not archived) — review")
        except Exception as e:
            err += 1; print(f"[{i}] ERR {dw} handle={handle}: {e}")
        time.sleep(0.5)
    if jf: jf.close()
    print(f"done ok={ok} skipped={skipped} err={err} " + ("(DRY-RUN — no writes)" if not APPLY else "(LIVE)"))

if __name__ == "__main__":
    main()