← 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 →