← back to Designer Wallcoverings

pending-approval/twil_golive2.py

151 lines

#!/usr/bin/env python3
"""TWIL grasscloth go-live v2 (Steve-approved: clean 9 + name/enrich/activate rest, leak-free).
 KEEPER -> activate+publish. DUP/leaky-twin -> archive.
 RAW-ONLY '| TWIL' -> retitle 'Fentucci {Material} Grasscloth Wallcovering{ — Color}',
   vendor=Fentucci, enrich (width/length/desc/color/img from sheet), then ACTIVATE iff has image+width; else DRAFT+Needs-Image.
 DRY default; CONFIRM=1 executes."""
import os,sys,json,time,re,urllib.request
import gspread
from collections import defaultdict
SHOP="designer-laboratory-sandbox.myshopify.com";API="2024-10"
DRY=os.environ.get("CONFIRM","0")!="1"
TOKEN=os.environ.get("SHOPIFY_ADMIN_TOKEN","")
if not TOKEN:
    for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if l.startswith("SHOPIFY_ADMIN_TOKEN="): TOKEN=l.strip().split("=",1)[1];break
BASE=f"https://{SHOP}/admin/api/{API}";H={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"}
def api(m,p,pl=None):
    d=json.dumps(pl).encode() if pl is not None else None
    r=urllib.request.Request(BASE+p,data=d,headers=H,method=m)
    with urllib.request.urlopen(r,timeout=60) as x: return x.status,json.loads(x.read().decode())
def gql(q,v=None): return api("POST","/graphql.json",{"query":q,"variables":v or {}})[1]
def norm(s): return (s or "").strip().upper().rstrip("T")
def tc(s): return " ".join(w[:1].upper()+w[1:].lower() if w else w for w in (s or "").split())

MATS=[("HERRINGBONE","Herringbone"),("SMALL DIAMOND","Small Diamond"),("PANAMA","Panama Weave"),
("PAPERWEAVE","Paperweave"),("PAPERWEAV","Paperweave"),("BURLAP","Burlap"),("RAW JUT","Jute"),("JUTE","Jute"),
("RAFFIA","Raffia"),("ABACA","Abaca"),("ARROWROOT","Arrowroot"),("GRANITE QUARTZ","Mica"),("MICA","Mica"),
("HALEY HEMP","Hemp"),("HEMP","Hemp"),("CORK","Cork"),("BOODLE","Boodle"),("TRIANGLE GRASS","Triangle Grass"),("SISAL","Sisal")]
jraw=json.load(open("/tmp/twil_prices.json"))
def material(n):
    for x in jraw:
        if norm(x["code"])==n:
            blob=(x.get("desc","")+" "+x.get("long","")).upper()
            for kw,lab in MATS:
                if kw in blob: return lab
    return ""
PRICE={norm(x["code"]):round(x["list_per_sr"]*0.75,2) for x in jraw if x["list_per_sr"] is not None}

# sheet enrichment
SA="/Users/macstudio3/Projects/secrets-manager/gmc-sa-146735262.json"
gc=gspread.service_account(filename=SA);ws=gc.open_by_key("1trKNm-ymqlbs96XJfndDUz19mzH8A11ktaw2lfQ2VQo").sheet1
vals=ws.get_all_values();SH=defaultdict(dict)
def norm_w(v):
    v=(v or '').strip().lower().replace('"',' inches').replace('inches','Inches')
    return tc(v) if v else ''
for r in vals[1:]:
    b=(r[1] or '').strip()
    if not b: continue
    n=norm(b);c=SH[n]
    def g(i): return (r[i] or '').strip() if len(r)>i else ''
    for k,i in [('img1',6),('img2',7),('alt',8),('desc',9),('color',10),('width',15),('length',16)]:
        if g(i) and not c.get(k): c[k]=g(i)
    SH[n]=c

# pull products
Q="""query($c:String){products(first:100,query:"vendor:Fentucci",after:$c){pageInfo{hasNextPage endCursor}
 edges{node{id legacyResourceId title status
  img:images(first:1){edges{node{id}}}
  w:metafield(namespace:"global",key:"width"){value} w2:metafield(namespace:"custom",key:"width"){value}
  mf:metafield(namespace:"custom",key:"manufacturer_sku"){value} mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}}}}}"""
prods=[];c=None
while True:
    d=gql(Q,{"c":c}).get("data",{}).get("products",{})
    for e in d["edges"]: prods.append(e["node"])
    if d["pageInfo"]["hasNextPage"]: c=d["pageInfo"]["endCursor"]
    else: break
grp=defaultdict(list)
for p in prods:
    mfr=(p.get("mf") or {}).get("value") or (p.get("mf2") or {}).get("value") or ""
    n=norm(mfr)
    if n not in PRICE: continue
    p["_n"]=n;p["_mfr"]=mfr;p["_leak"]="| TWIL" in p["title"]
    p["_named"]=("| Fentucci" in p["title"]) and not re.match(rf"^{re.escape(n)}T?\b",p["title"].upper())
    p["_img"]=len(p["img"]["edges"])>0
    p["_width"]=bool((p.get("w") or {}).get("value") or (p.get("w2") or {}).get("value"))
    grp[n].append(p)

acts=[];arch=[];retitle=[]
for n,ps in grp.items():
    keepers=[p for p in ps if p["_named"] or p["status"]=="ACTIVE"]
    if keepers:
        keepers.sort(key=lambda z:(z["status"]!="ACTIVE",-len(z["title"])))
        keep=keepers[0]
        for p in ps:
            if p is keep:
                if keep["status"]!="ACTIVE": acts.append(keep)
            else: arch.append(p)
    else:
        ps.sort(key=lambda z:(not z["_img"],not z["_width"]))
        retitle.append(ps[0]); arch.extend(ps[1:])

def newtitle(p):
    mat=material(p["_n"]);col=tc(SH[p["_n"]].get("color",""))
    return re.sub(r"\s+"," ",f"Fentucci {mat+' ' if mat else ''}Grasscloth Wallcovering{(' — '+col) if col else ''}").strip()

print("=== GO-LIVE PLAN v2 ===")
print(f"ACTIVATE clean keepers: {len(acts)}")
for p in acts: print(f"   {p['_mfr']:>9} {p['title'][:48]}")
print(f"\nRETITLE+ENRICH raw-only: {len(retitle)}")
will_act=0
for p in retitle:
    s=SH[p["_n"]];img= '1' if s.get('img1') else '0'
    gate = bool(s.get('img1')) and bool(s.get('width') or True)
    if gate: will_act+=1
    print(f"   {p['_mfr']:>9} -> {newtitle(p)[:52]:52} img={img} w={'Y' if s.get('width') else '-'} desc={'Y' if s.get('desc') else '-'} -> {'ACTIVATE' if gate else 'DRAFT/Needs-Image'}")
print(f"   (of {len(retitle)} raw: {will_act} will ACTIVATE, {len(retitle)-will_act} stay DRAFT Needs-Image)")
print(f"\nARCHIVE dups/leaky twins: {len(arch)}")
for p in arch: print(f"   {p['_mfr']:>9} [{p['status']}] {p['title'][:46]}")

if DRY:
    print("\n[DRY] CONFIRM=1 to execute."); sys.exit()

# ---- EXECUTE ----
# GMC source-fix (Steve policy, 2026-07-08): exclude "Google & YouTube"
# (publication 29646651457). Shopify auto-syncs minVariantPrice ($4.25 sample)
# to Merchant Center -> price disapproval. Google is fed by the controlled TSV
# feed only. Rollback: delete the filter below.
PUBS=[e["node"]["id"] for e in gql("{publications(first:50){edges{node{id name}}}}").get("data",{}).get("publications",{}).get("edges",[]) if e["node"]["id"]!="gid://shopify/Publication/29646651457"]
def publish(pid):
    return gql("""mutation($id:ID!,$in:[PublicationInput!]!){publishablePublish(id:$id,input:$in){userErrors{message}}}""",
       {"id":f"gid://shopify/Product/{pid}","in":[{"publicationId":x} for x in PUBS]}).get("data",{}).get("publishablePublish",{}).get("userErrors",[])
def set_mf(pid,ns,key,val):
    if not val: return
    api("POST",f"/products/{pid}/metafields.json",{"metafield":{"namespace":ns,"key":key,"type":"single_line_text_field","value":str(val)}})
na=nr=nact=nar=0
for p in acts:
    api("PUT",f"/products/{p['legacyResourceId']}.json",{"product":{"id":p['legacyResourceId'],"status":"active"}})
    publish(p['legacyResourceId']);na+=1;print(f"  activated {p['_mfr']}");time.sleep(0.4)
for p in retitle:
    pid=p['legacyResourceId'];s=SH[p["_n"]];nt=newtitle(p);col=tc(s.get("color",""))
    imgs=[{"src":u} for u in [s.get('img1'),s.get('img2')] if u]
    width=norm_w(s.get('width')) or "36 Inches"; length=tc(s.get('length','')) or "8 Yards"
    tags=["Grasscloth","Natural Wallcovering","Fentucci","TWIL Naturals","display_variant"]
    gate=bool(imgs)
    if not gate: tags.append("Needs-Image")
    if col: tags.append(f"color:{col}")
    prod={"id":pid,"title":nt,"vendor":"Fentucci","tags":", ".join(tags)}
    if s.get('desc'): prod["body_html"]=s['desc']
    if imgs: prod["images"]=imgs
    if gate: prod["status"]="active"
    api("PUT",f"/products/{pid}.json",{"product":prod})
    set_mf(pid,"global","width",width); set_mf(pid,"global","length",length)
    if col: set_mf(pid,"custom","color",col); set_mf(pid,"global","Color-Way",col)
    set_mf(pid,"custom","manufacturer_sku",p["_mfr"]); set_mf(pid,"dwc","manufacturer_sku",p["_mfr"])
    if gate: publish(pid); nact+=1
    nr+=1;print(f"  retitled {p['_mfr']} -> {nt} {'ACTIVE' if gate else 'DRAFT(Needs-Image)'}");time.sleep(0.5)
for p in arch:
    api("PUT",f"/products/{p['legacyResourceId']}.json",{"product":{"id":p['legacyResourceId'],"status":"archived"}})
    nar+=1;print(f"  archived {p['_mfr']} ({p['title'][:28]})");time.sleep(0.3)
print(f"\nDONE — keepers activated {na}, retitled {nr} (of which activated {nact}), archived {nar}")