← back to Dw Wallpaper Cleanup
revert_all.py
266 lines
#!/usr/bin/env python3
"""REVERT — Steve reversed the banned-word cleanup. Keep "Wallpaper".
Reverses EXACTLY what THIS session's Job 1 (tags) and Job 2 (generic vendors +
coupled smart-collection VENDOR rules) changed. DTD verdict A (2026-06-22):
full revert so ZERO "...Wallcoverings" survives for the generic vendors, in
BOTH Postgres (source of truth) AND Shopify, PG-first.
Surgical scope:
TAGS — only the tokens Job 1 added, reversed per-product from
job1_apply-rest.json + job1_canary.json (238 products).
VENDOR — the 5 generic vendors only:
PS Removable Wallcoverings -> PS Removable Wallpaper (no coupled coll)
DW Exclusive Wallcoverings -> DW Exclusive Wallpaper (no coupled coll)
Apartment Wallcoverings -> Apartment Wallpaper (2 coupled colls)
Wallcovering NYC -> Wallpaper NYC (1 coupled coll)
Malibu Wallcoverings -> Malibu Wallpaper (1 coupled coll, disjunctive)
HELD — Scalamandre/Roberto Cavalli/Laura Ashley/Missoni/MC Escher Wallpaper:
never touched, never touched here.
PG-first, then Shopify. ACTIVE-and-all-status (matches how they were renamed).
Vendor/tag edits create NO variants. Idempotent + synchronous + rate-limited.
$0 (free Shopify Admin API + local psql).
"""
import json, re, sys, time, subprocess, urllib.request, urllib.error
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"
URL = f"https://{DOMAIN}/admin/api/2024-10/graphql.json"
HERE = "/Users/macstudio3/Projects/dw-wallpaper-cleanup"
# ---- generic vendor reverts: new(current) -> old(restore) ----
VENDOR_REVERT = {
"PS Removable Wallcoverings": "PS Removable Wallpaper",
"DW Exclusive Wallcoverings": "DW Exclusive Wallpaper",
"Apartment Wallcoverings": "Apartment Wallpaper",
"Wallcovering NYC": "Wallpaper NYC",
"Malibu Wallcoverings": "Malibu Wallpaper",
}
# coupled smart-collection VENDOR rule reverts: collection gid -> (current_cond -> restore_cond)
COUPLED = {
"gid://shopify/Collection/298970972211": ("Apartment Wallcoverings", "Apartment Wallpaper"),
"gid://shopify/Collection/299533828147": ("Apartment Wallcoverings", "Apartment Wallpaper"),
"gid://shopify/Collection/298971430963": ("Wallcovering NYC", "Wallpaper NYC"),
"gid://shopify/Collection/298507403315": ("Malibu Wallcoverings", "Malibu Wallpaper"),
}
HELD = ["Scalamandre Wallpaper", "Roberto Cavalli Wallpaper", "Laura Ashley Wallpaper",
"Missoni Wallpaper", "MC Escher Wallpaper"]
def gql(q, v=None):
body = {"query": q}
if v is not None:
body["variables"] = v
req = urllib.request.Request(URL, data=json.dumps(body).encode(),
headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
for a in range(8):
try:
d = json.load(urllib.request.urlopen(req, timeout=60))
cost = d.get("extensions", {}).get("cost", {}).get("throttleStatus", {})
if cost.get("currentlyAvailable", 4000) < 400:
time.sleep(2)
return d
except urllib.error.HTTPError as e:
if e.code == 429:
time.sleep(3 * (a + 1)); continue
time.sleep(2 * (a + 1))
except Exception:
time.sleep(2 * (a + 1))
raise RuntimeError("gql failed")
def psql(sql):
subprocess.run(["psql", "-d", "dw_unified", "-v", "ON_ERROR_STOP=1", "-c", sql],
check=True, capture_output=True, text=True)
def vcount_all(v):
return gql('{ productsCount(query:%s){count} }' % json.dumps('vendor:"%s"' % v))["data"]["productsCount"]["count"]
# ============================ JOB 1 REVERT: TAGS ============================
TAGS_ADD = """mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{field message} } }"""
TAGS_REMOVE = """mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id, tags:$tags){ userErrors{field message} } }"""
def load_tag_changes():
"""Merge canary(3) + apply-rest(235) = 238 per-product Job-1 records."""
recs = []
for fn in ("job1_canary.json", "job1_apply-rest.json"):
recs += json.load(open(f"{HERE}/{fn}"))
# only APPLIED records carry a real add/remove to reverse
out = []
for r in recs:
if r.get("status") != "APPLIED":
continue
out.append(r)
return out
def fetch_tags(pid):
q = '{ product(id:"%s"){ id status tags } }' % pid
return gql(q)["data"]["product"]
def _revert_one_tag(c, apply=False, gap=0.6):
pid = c["id"]
to_remove = c["added"]
to_add = c["removed"]
node = fetch_tags(pid)
if not node:
return {"id": pid, "status": "NOT_FOUND"}
cur = set(node["tags"])
still_added = [t for t in to_remove if t in cur]
already = all(t in cur for t in to_add) and not still_added
if already:
return {"id": pid, "handle": c.get("handle"), "status": "ALREADY_REVERTED"}
final = [t for t in node["tags"] if t not in to_remove]
for t in to_add:
if t not in final:
final.append(t)
rec = {"id": pid, "handle": c.get("handle"),
"remove": to_remove, "add": to_add, "final": final, "status": "PLANNED"}
if apply:
num = pid.rsplit("/", 1)[-1]
gid = f"gid://shopify/Product/{num}"
tagstr = ", ".join(final).replace("'", "''")
psql("UPDATE shopify_products SET tags='%s' WHERE shopify_id='%s' OR shopify_id='%s';"
% (tagstr, num, gid))
ra = gql(TAGS_ADD, {"id": pid, "tags": to_add})["data"]["tagsAdd"]["userErrors"]
rr = gql(TAGS_REMOVE, {"id": pid, "tags": to_remove})["data"]["tagsRemove"]["userErrors"]
rec["status"] = "REVERTED" if not ra and not rr else "ERROR"
if ra or rr:
rec["errors"] = {"add": ra, "remove": rr}
time.sleep(gap)
return rec
def revert_tags(apply=False, gap=0.6):
changes = load_tag_changes()
results = []
for i, c in enumerate(changes):
results.append(_revert_one_tag(c, apply=apply, gap=gap))
if (i + 1) % 25 == 0:
sys.stderr.write(f" tags {i+1}/{len(changes)}\n")
return results
# ====================== JOB 2 REVERT: VENDORS + RULES ======================
PROD_UPDATE = """mutation($input:ProductInput!){ productUpdate(input:$input){ product{id vendor} userErrors{field message} } }"""
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 revert_collection_rule(gid, cur_cond, restore_cond, apply=False):
c = gql(COLL_RULE_Q % gid)["data"]["collection"]
rs = c.get("ruleSet")
if not rs:
return {"coll": gid, "action": "NO_RULESET"}
rules = rs["rules"]
has_cur = any(r["column"] == "VENDOR" and r["relation"] == "EQUALS" and r["condition"] == cur_cond for r in rules)
has_restore = any(r["column"] == "VENDOR" and r["relation"] == "EQUALS" and r["condition"] == restore_cond for r in rules)
if has_restore and not has_cur:
return {"coll": c["title"], "action": "ALREADY_REVERTED"}
if not has_cur:
return {"coll": c["title"], "action": "CUR_RULE_NOT_FOUND"}
new_rules = []
for r in rules:
if r["column"] == "VENDOR" and r["relation"] == "EQUALS" and r["condition"] == cur_cond:
if not has_restore:
new_rules.append({"column": "VENDOR", "relation": "EQUALS", "condition": restore_cond})
# if restore already present, drop the cur (dedup)
else:
new_rules.append({"column": r["column"], "relation": r["relation"], "condition": r["condition"]})
if not apply:
return {"coll": c["title"], "action": "PLANNED"}
inp = {"id": gid, "ruleSet": {"appliedDisjunctively": rs["appliedDisjunctively"], "rules": new_rules}}
res = gql(COLL_UPDATE, {"input": inp})["data"]["collectionUpdate"]
return {"coll": c["title"], "action": "RULE_REVERTED" if not res["userErrors"] else "RULE_ERROR",
"errors": res["userErrors"]}
def fetch_all_status(vendor):
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 vendor}} } }') % (
json.dumps('vendor:"%s"' % vendor), after)
d = gql(q)["data"]["products"]
for e in d["edges"]:
if e["node"]["vendor"] == vendor:
out.append(e["node"]["id"])
if not d["pageInfo"]["hasNextPage"]:
break
cursor = d["pageInfo"]["endCursor"]
return out
def revert_vendor(cur, restore, apply=False, gap=0.5):
rec = {"current": cur, "restore": restore}
rec["before"] = {"cur_all": vcount_all(cur), "restore_all": vcount_all(restore)}
# coupled rules for this vendor (restore FIRST so membership preserved)
rec["rule_actions"] = []
for gid, (ccur, crestore) in COUPLED.items():
if ccur == cur:
a = revert_collection_rule(gid, ccur, crestore, apply=apply)
rec["rule_actions"].append({"gid": gid, **a})
time.sleep(0.4)
if apply:
# PG FIRST — all statuses
psql("UPDATE shopify_products SET vendor='%s' WHERE vendor='%s';"
% (restore.replace("'", "''"), cur.replace("'", "''")))
rec["pg"] = "OK"
ids = fetch_all_status(cur)
rec["to_revert"] = len(ids)
reverted = 0; errors = []
for i, pid in enumerate(ids):
res = gql(PROD_UPDATE, {"input": {"id": pid, "vendor": restore}})["data"]["productUpdate"]
if res["userErrors"]:
errors.append({"id": pid, "errs": res["userErrors"]})
else:
reverted += 1
time.sleep(gap)
if (i + 1) % 50 == 0:
sys.stderr.write(f" {cur}: {i+1}/{len(ids)} reverted\n")
rec["reverted"] = reverted; rec["errors"] = errors
time.sleep(3)
rec["after"] = {"cur_all": vcount_all(cur), "restore_all": vcount_all(restore)}
rec["status"] = "OK" if (rec["after"]["cur_all"] == 0 and not errors) else "CHECK"
return rec
# ================================ MAIN ================================
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "plan"
apply = mode in ("apply", "tags-only", "vendors-only", "canary-tags")
out = {"mode": mode}
if mode in ("plan", "apply", "tags-only", "canary-tags"):
if mode == "canary-tags":
n = int(sys.argv[2]) if len(sys.argv) > 2 else 3
changes = load_tag_changes()[:n]
out["tags"] = [_revert_one_tag(c, apply=True) for c in changes]
else:
out["tags"] = revert_tags(apply=(mode in ("apply", "tags-only")))
if mode in ("plan", "apply", "vendors-only"):
out["vendors"] = []
for cur, restore in VENDOR_REVERT.items():
out["vendors"].append(revert_vendor(cur, restore, apply=(mode in ("apply", "vendors-only"))))
json.dump(out, open(f"{HERE}/revert_{mode}.json", "w"), indent=2)
if "tags" in out:
print("TAGS:", dict(Counter(r.get("status") for r in out["tags"])))
if "vendors" in out:
for v in out["vendors"]:
print(f"VENDOR {v['current']} -> {v['restore']}: status={v.get('status','PLAN')} "
f"reverted={v.get('reverted','-')} after_cur={v.get('after',{}).get('cur_all','-')} "
f"after_restore={v.get('after',{}).get('restore_all','-')} "
f"rules={[a.get('action') for a in v['rule_actions']]}")
print("AUDIT:", f"{HERE}/revert_{mode}.json")