← back to Reid Witlin Onboarding

rename.py

153 lines

#!/usr/bin/env python3
"""
Reid Witlin SKU unification — GATED live rename (Steve runs this).

Renames the 315 already-live Reid Witlin products from DWRW-210xxx to
DWDQ-210xxx (prefix swap, number preserved) so the whole line lives under the
dedicated DWDQ prefix. Operates on LIVE variants (SKU is on the InventoryItem in
API 2024-10), covering the product SKU AND its -Sample variant. Then mirrors the
change into dw_unified.

SAFETY:
  * DRY_RUN=1 by default — lists every rename it WOULD do, writes nothing.
  * status:any so ACTIVE + ARCHIVED are both caught (DELETED ones are gone → skipped).
  * Number is preserved; only the DWRW->DWDQ prefix changes. Fully reversible.
  * Smoke test one: DRY_RUN=0 LIMIT=1 python3 rename.py
"""
import os, json, time, urllib.request, subprocess

HERE = os.path.dirname(os.path.abspath(__file__))
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LIMIT = int(os.environ.get("LIMIT", "0")) or None

def _tok():
    for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if line.startswith("SHOPIFY_ADMIN_TOKEN="):
            return line.split("=", 1)[1].strip().strip('"')
    raise SystemExit("SHOPIFY_ADMIN_TOKEN not found")

TOKEN = os.environ.get("AT") or _tok()
URL = "https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json"

def gql(q, v=None):
    body = json.dumps({"query": q, "variables": v or {}}).encode()
    req = urllib.request.Request(URL, body,
        {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
    for a in range(8):
        try:
            d = json.load(urllib.request.urlopen(req, timeout=90))
            if "errors" in d and any("THROTTLED" in str(e) for e in d["errors"]):
                time.sleep(2 * (a + 1)); continue
            return d
        except Exception:
            time.sleep(2 * (a + 1))
    raise RuntimeError("gql failed")

FIND = """
query($q:String!,$c:String){
  products(first:50, query:$q, after:$c){
    pageInfo{hasNextPage endCursor}
    edges{node{ id title status tags
      variants(first:20){edges{node{ id sku }}}
    }}
  }
}"""

# PL-name-leaking tags to strip during the rename (Steve: yes, strip it)
LEAK_TAGS = ["Reid Witlin"]

TAGS_REMOVE = """
mutation($id:ID!,$tags:[String!]!){
  tagsRemove(id:$id, tags:$tags){ userErrors{ message } }
}"""

UPDATE = """
mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkUpdate(productId:$pid, variants:$variants){
    productVariants{ id sku }
    userErrors{ field message }
  }
}"""

def new_sku(old):
    # DWRW-210314 -> DWDQ-210314 ; DWRW-210314-Sample -> DWDQ-210314-Sample
    return old.replace("DWRW-210", "DWDQ-210", 1) if old and old.startswith("DWRW-210") else old

def main():
    # gather every product with a DWRW-210xxx variant. Shopify tokenizes on '-',
    # so 'sku:DWRW-210*' matches nothing; query the broad 'sku:DWRW*' and filter
    # to the 210xxx band in code (this also excludes Rebel Walls' 360xxx/76xxx).
    # Default query omits ARCHIVED, so run an explicit archived pass too.
    targets, seen = [], set()
    for qbase in ("sku:DWRW*", "sku:DWRW* status:archived"):
        cur = None
        while True:
            d = gql(FIND, {"q": qbase, "c": cur})
            p = d["data"]["products"]
            for e in p["edges"]:
                n = e["node"]
                if n["id"] in seen:
                    continue
                vs = [(v["node"]["id"], v["node"]["sku"]) for v in n["variants"]["edges"]
                      if (v["node"]["sku"] or "").startswith("DWRW-210")]
                if vs:
                    seen.add(n["id"])
                    leaks = [t for t in (n.get("tags") or []) if t in LEAK_TAGS]
                    targets.append({"pid": n["id"], "title": n["title"], "status": n["status"],
                                    "variants": vs, "leak_tags": leaks})
            if p["pageInfo"]["hasNextPage"]:
                cur = p["pageInfo"]["endCursor"]
            else:
                break
    if LIMIT:
        targets = targets[:LIMIT]
    n_var = sum(len(t["variants"]) for t in targets)
    n_leak = sum(1 for t in targets if t["leak_tags"])
    print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(targets)} products / {n_var} variants "
          f"DWRW-210xxx -> DWDQ-210xxx  |  strip 'Reid Witlin' tag on {n_leak}")

    done, errs, remap, stripped = 0, [], [], 0
    for i, t in enumerate(targets):
        updates = [{"id": vid, "inventoryItem": {"sku": new_sku(old)}} for vid, old in t["variants"]]
        for vid, old in t["variants"]:
            remap.append({"old": old, "new": new_sku(old)})
        if DRY_RUN:
            if i < 4 or t["leak_tags"]:
                extra = f"  [+strip {t['leak_tags']}]" if t["leak_tags"] else ""
                print(f"  would rename [{t['status']}] {t['title'][:30]!r}: "
                      + ", ".join(f"{o}->{new_sku(o)}" for _, o in t["variants"]) + extra)
            done += 1
            continue
        d = gql(UPDATE, {"pid": t["pid"], "variants": updates})
        r = (d.get("data") or {}).get("productVariantsBulkUpdate") or {}
        ue = r.get("userErrors") or []
        if ue:
            errs.append({"title": t["title"], "errors": ue[:2]})
        else:
            done += 1
        # strip PL-name-leaking tag(s) where present (idempotent)
        if t["leak_tags"]:
            gql(TAGS_REMOVE, {"id": t["pid"], "tags": t["leak_tags"]})
            stripped += 1
        if i % 25 == 0:
            print(f"  ...{i}/{len(targets)} done={done} errs={len(errs)}")
        time.sleep(0.3)

    # mirror the change into dw_unified (both mirror + catalog), non-fatal
    if not DRY_RUN and done:
        for tbl, col in [("shopify_products", "sku"), ("rwltd_catalog", "dw_sku")]:
            subprocess.run(["psql", "host=/tmp dbname=dw_unified", "-c",
                f"UPDATE {tbl} SET {col}=replace({col},'DWRW-210','DWDQ-210') "
                f"WHERE {col} LIKE 'DWRW-210%';"], capture_output=True, text=True)

    out = {"products": len(targets), "variants": n_var, "renamed": done,
           "reid_witlin_tag_stripped": stripped, "errors": errs[:20], "remap_sample": remap[:6]}
    json.dump(out, open(os.path.join(HERE, "rename-results.json"), "w"), indent=2)
    print(f"\nDONE. products={done} userErrors={len(errs)} "
          f"({'DRY-RUN — nothing written' if DRY_RUN else 'live SKUs renamed + mirror synced'})")
    if errs:
        print("sample errors:", json.dumps(errs[:3]))

if __name__ == "__main__":
    main()