← 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")