[object Object]

← back to Dw Wallpaper Cleanup

Job 2 vendor rename script: 5 generic vendors + coupled collection-rule lockstep, brands held

bd0f6d55f4075b0e37ddeb9e18ef617f587699b4 · 2026-06-22 10:52:12 -0700 · Steve

Files touched

Diff

commit bd0f6d55f4075b0e37ddeb9e18ef617f587699b4
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 22 10:52:12 2026 -0700

    Job 2 vendor rename script: 5 generic vendors + coupled collection-rule lockstep, brands held
---
 job2_vendor.py | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 152 insertions(+)

diff --git a/job2_vendor.py b/job2_vendor.py
new file mode 100644
index 0000000..3ef4d36
--- /dev/null
+++ b/job2_vendor.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""JOB 2 — scrub banned word 'Wallpaper' from generic-descriptor vendor field.
+DTD verdict A: rename all 5 generic vendors AND update the 3 coupled
+smart-collection VENDOR rule conditions in lockstep so membership is preserved.
+HOLD proper-noun brand vendors (Scalamandre/Roberto Cavalli/Laura Ashley/
+Missoni/MC Escher Wallpaper) — never touched.
+PG-first mirror to dw_unified.shopify_products, then Shopify.
+ACTIVE products only. Vendor edits create NO variants.
+"""
+import json, re, sys, time, urllib.request, subprocess
+from collections import Counter
+
+TOKEN = subprocess.check_output(
+    "grep -E '^SHOPIFY_ADMIN_TOKEN=' ~/Projects/secrets-manager/.env | cut -d= -f2",
+    shell=True, text=True).strip()
+DOMAIN = "designer-laboratory-sandbox.myshopify.com"
+VER = "2024-10"
+URL = f"https://{DOMAIN}/admin/api/{VER}/graphql.json"
+
+# generic vendor -> new value (proper-noun brands deliberately ABSENT = HELD)
+RENAME = {
+    "Malibu Wallpaper": "Malibu Wallcoverings",
+    "PS Removable Wallpaper": "PS Removable Wallcoverings",
+    "Apartment Wallpaper": "Apartment Wallcoverings",
+    "Wallpaper NYC": "Wallcovering NYC",
+    "DW Exclusive Wallpaper": "DW Exclusive Wallcoverings",
+}
+HELD = ["Scalamandre Wallpaper", "Roberto Cavalli Wallpaper", "Laura Ashley Wallpaper",
+        "Missoni Wallpaper", "MC Escher Wallpaper"]
+
+def gql(query, variables=None):
+    body = {"query": query}
+    if variables is not None:
+        body["variables"] = variables
+    req = urllib.request.Request(URL, data=json.dumps(body).encode(),
+        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+    for attempt in range(6):
+        try:
+            d = json.load(urllib.request.urlopen(req, timeout=60))
+            cost = d.get("extensions", {}).get("cost", {}).get("throttleStatus", {})
+            if cost.get("currentlyAvailable", 1000) < 300:
+                time.sleep(2)
+            return d
+        except urllib.error.HTTPError as e:
+            if e.code == 429:
+                time.sleep(3 * (attempt + 1)); continue
+            time.sleep(2 * (attempt + 1))
+        except Exception:
+            time.sleep(2 * (attempt + 1))
+    raise RuntimeError("gql failed")
+
+PROD_UPDATE = """mutation($input:ProductInput!){ productUpdate(input:$input){ product{id vendor title} userErrors{field message} } }"""
+
+def fetch_vendor_products(vendor):
+    """All ACTIVE product gids+titles for a vendor (exact match)."""
+    out = []
+    cursor = None
+    while True:
+        after = ', after:"%s"' % cursor if cursor else ''
+        q = ('{ products(first:250, query:%s%s){ pageInfo{hasNextPage endCursor} '
+             'edges{ node{ id title vendor status } } } }') % (
+             json.dumps('status:active AND vendor:"%s"' % vendor), after)
+        d = gql(q)["data"]["products"]
+        for e in d["edges"]:
+            n = e["node"]
+            if n["vendor"] == vendor and n["status"] == "ACTIVE":
+                out.append({"id": n["id"], "title": n["title"]})
+        if not d["pageInfo"]["hasNextPage"]:
+            break
+        cursor = d["pageInfo"]["endCursor"]
+    return out
+
+def mirror_pg(old_vendor, new_vendor):
+    subprocess.run(
+        ["psql", "-d", "dw_unified", "-v", "ON_ERROR_STOP=1", "-c",
+         "UPDATE shopify_products SET vendor=$$%s$$ WHERE status='ACTIVE' AND vendor=$$%s$$;"
+         % (new_vendor, old_vendor)],
+        check=True, capture_output=True, text=True)
+
+# --- smart collection rule update ---
+COLL_RULE_Q = """{ collection(id:"%s"){ id title ruleSet{ appliedDisjunctively rules{ column relation condition } } } }"""
+COLL_UPDATE = """mutation($input:CollectionInput!){ collectionUpdate(input:$input){ collection{id title} userErrors{field message} } }"""
+
+def update_collection_rule(coll_gid, old_cond, new_cond):
+    """Swap a single VENDOR EQUALS rule's condition old->new, preserving all others."""
+    cur = gql(COLL_RULE_Q % coll_gid)["data"]["collection"]
+    rs = cur["ruleSet"]
+    new_rules = []
+    changed = False
+    for r in rs["rules"]:
+        if r["column"] == "VENDOR" and r["relation"] == "EQUALS" and r["condition"] == old_cond:
+            new_rules.append({"column": "VENDOR", "relation": "EQUALS", "condition": new_cond})
+            changed = True
+        else:
+            new_rules.append({"column": r["column"], "relation": r["relation"], "condition": r["condition"]})
+    if not changed:
+        return {"status": "RULE_NOT_FOUND", "coll": cur["title"]}
+    inp = {"id": coll_gid, "ruleSet": {
+        "appliedDisjunctively": rs["appliedDisjunctively"], "rules": new_rules}}
+    res = gql(COLL_UPDATE, {"input": inp})["data"]["collectionUpdate"]
+    return {"status": "RULE_UPDATED" if not res["userErrors"] else "RULE_ERROR",
+            "coll": cur["title"], "errors": res["userErrors"]}
+
+def rename_vendor(old_vendor, new_vendor, coupled_collections, apply=False, gap=0.55):
+    """coupled_collections = list of {id(gid), title}. Update rules FIRST, then rename products."""
+    rec = {"old": old_vendor, "new": new_vendor, "coupled": [], "renamed": 0,
+           "errors": [], "status": "PLANNED"}
+    prods = fetch_vendor_products(old_vendor)
+    rec["product_count"] = len(prods)
+    if apply:
+        # 1) update coupled collection rules to new condition first
+        for c in coupled_collections:
+            r = update_collection_rule(c["id"], old_vendor, new_vendor)
+            rec["coupled"].append(r)
+            if r["status"] not in ("RULE_UPDATED",):
+                rec["errors"].append(r)
+            time.sleep(0.4)
+        # 2) PG mirror
+        mirror_pg(old_vendor, new_vendor)
+        # 3) rename each product's vendor
+        for i, p in enumerate(prods):
+            res = gql(PROD_UPDATE, {"input": {"id": p["id"], "vendor": new_vendor}})["data"]["productUpdate"]
+            if res["userErrors"]:
+                rec["errors"].append({"id": p["id"], "errs": res["userErrors"]})
+            else:
+                rec["renamed"] += 1
+            time.sleep(gap)
+            if (i + 1) % 50 == 0:
+                sys.stderr.write(f"    {old_vendor}: {i+1}/{len(prods)}\n")
+        rec["status"] = "APPLIED" if not rec["errors"] else "PARTIAL"
+    return rec
+
+if __name__ == "__main__":
+    coupled_map = json.load(open("/tmp/job2_coupled.json"))
+    # dedup coupled collections per vendor by gid
+    def coup(v):
+        seen = {}
+        for c in coupled_map.get(v, []):
+            seen[c["id"]] = {"id": c["id"], "title": c["title"]}
+        return list(seen.values())
+
+    mode = sys.argv[1] if len(sys.argv) > 1 else "plan"
+    if mode == "plan":
+        for v, nv in RENAME.items():
+            r = rename_vendor(v, nv, coup(v), apply=False)
+            print(f"{r['product_count']:>6}  {v!r} -> {nv!r}  coupled={len(coup(v))}")
+        print("HELD:", HELD)
+    elif mode == "one":
+        v = sys.argv[2]; nv = RENAME[v]
+        r = rename_vendor(v, nv, coup(v), apply=True)
+        json.dump(r, open(f"/tmp/job2_{v.replace(' ','_')}.json", "w"), indent=2)
+        print("STATUS:", r["status"], "renamed:", r["renamed"], "coupled:", r["coupled"], "errors:", len(r["errors"]))

← 3c2f686 Job 1 LANDED: 238 active products wallpaper->wallcovering in  ·  back to Dw Wallpaper Cleanup  ·  Job 2 partial LANDED: PS Removable (736) + DW Exclusive (1) 49c5322 →