← back to Reid Witlin Onboarding

activate_batch.py

121 lines

#!/usr/bin/env python3
"""
Reid Witlin (Architectural Fabrics PL) — activate the 1,005 DRAFT products
Steve approved live in conversation 2026-09-03 ("make them active with our
pl - architectural fabrics line"). These are quote-only, single-Sample-variant
products (same shape as the original 190 already-ACTIVE Reid Witlin products) —
structurally excluded from the automatic rotation-activator cadence (their SKU
matches SAMPLE_SKU_REGEX), so this is a direct, one-time scripted activation,
not a cadence bug fix.

SAFETY:
  * DRY_RUN=1 by default.
  * Reads the live product set fresh via GraphQL (vendor:'Architectural Fabrics'
    status:draft) rather than any local file, so it reflects codex's overnight
    race-twin cleanup.
  * status -> ACTIVE only; no other field touched.
"""
import os, json, time, urllib.request

DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LIMIT = int(os.environ.get("LIMIT", "0")) or None

def _tok():
    p = os.path.expanduser("~/Projects/secrets-manager/.env")
    for line in open(p):
        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")

LIST_Q = """
query($cursor: String) {
  products(first: 250, after: $cursor, query: "vendor:'Architectural Fabrics' status:draft") {
    pageInfo { hasNextPage endCursor }
    nodes { id handle title descriptionHtml }
  }
}"""

ACTIVATE_M = """
mutation($input: ProductInput!) {
  productUpdate(input: $input) {
    product { id status }
    userErrors { field message }
  }
}"""

def fetch_all():
    out, cursor = [], None
    while True:
        d = gql(LIST_Q, {"cursor": cursor})
        data = d.get("data", {}).get("products", {})
        out.extend(data.get("nodes", []))
        pi = data.get("pageInfo", {})
        if not pi.get("hasNextPage"):
            return out
        cursor = pi.get("endCursor")

def main():
    products = fetch_all()
    # GATE (TK-11256, 2026-09-04): every other DW onboarder's go-live.mjs holds a product
    # as DRAFT when descriptionHtml is empty (see maharam/osborne/knoll-onboard go-live.mjs
    # "even if create-drafts shipped an empty descriptionHtml, this HOLDS the product as
    # draft"). This script had no such check -- the 2026-09-03 1,005-item activation shipped
    # every product with no description and tripped dw-five-field-canary. This does NOT
    # touch anything already live; it only protects future runs of this script (e.g. the
    # 275 rows still pending onboarding).
    held_no_desc = [p for p in products
                    if not (p.get("descriptionHtml") or "").replace("<p>", "").replace("</p>", "").strip()]
    if held_no_desc:
        print(f"HOLDING {len(held_no_desc)}/{len(products)} as DRAFT (no descriptionHtml) -- "
              f"see held-no-description.json")
        json.dump([{"id": p["id"], "handle": p["handle"]} for p in held_no_desc],
                   open("held-no-description.json", "w"), indent=2)
    products = [p for p in products if p not in held_no_desc]
    if LIMIT:
        products = products[:LIMIT]
    print(f"{'DRY-RUN' if DRY_RUN else 'LIVE'}: {len(products)} products "
          f"({'no writes' if DRY_RUN else 'activating on LIVE store'})")
    activated, errs = 0, []
    for i, p in enumerate(products):
        if DRY_RUN:
            if i < 3:
                print(f"  would activate {p['handle']} ({p['title']})")
            activated += 1
            continue
        d = gql(ACTIVATE_M, {"input": {"id": p["id"], "status": "ACTIVE"}})
        r = (d.get("data") or {}).get("productUpdate") or {}
        ue = r.get("userErrors") or []
        if ue:
            errs.append({"id": p["id"], "handle": p["handle"], "errors": ue[:2]})
        else:
            activated += 1
        if i % 50 == 0:
            print(f"  ...{i}/{len(products)} activated={activated} errs={len(errs)}")
        time.sleep(0.25)
    out = {"activated": activated, "errors": errs[:20], "total": len(products)}
    json.dump(out, open("activate-results.json", "w"), indent=2)
    print(f"\nDONE. activated={activated} userErrors={len(errs)} "
          f"({'DRY-RUN — nothing written' if DRY_RUN else 'ACTIVE on live store'})")
    if errs:
        print("sample errors:", json.dumps(errs[:3]))

if __name__ == "__main__":
    main()