← back to Dw Wallpaper Cleanup

job2_vendor.py

153 lines

#!/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"]))