← back to Designer Wallcoverings

pending-approval/burst_twil_naturals.py

152 lines

#!/usr/bin/env python3
"""
burst_twil_naturals.py — raw burst-create the 42 NEW Fentucci/TWIL grasscloth
products on Shopify (Steve-authorized override of cadence-only, 2026-06-24).

Each product: DRAFT status (activation gate — no publish without confirmed cost/
full specs), Sample + Roll variants, image(s), description, and FULL metafields.
Idempotent: skips any dw_sku that already exists. Dedup already done upstream
(only genuinely-new patterns are in /tmp/burst_records.json).

  DRY (default): bash/python preview, no writes.
  CONFIRM=1 python3 burst_twil_naturals.py   -> creates live (draft) products.
"""
import os, sys, json, time, urllib.request, urllib.parse

SHOP="designer-laboratory-sandbox.myshopify.com"; API="2024-10"
DATA="/tmp/burst_records.json"
SAMPLE_PRICE="4.25"
DRY = os.environ.get("CONFIRM","0") != "1"

TOKEN=os.environ.get("SHOPIFY_ADMIN_TOKEN","")
if not TOKEN:
    for line in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
        if line.startswith("SHOPIFY_ADMIN_TOKEN="):
            TOKEN=line.strip().split("=",1)[1]; break
assert TOKEN, "no SHOPIFY_ADMIN_TOKEN"
BASE=f"https://{SHOP}/admin/api/{API}"
H={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"}

def api(method, path, payload=None):
    data=json.dumps(payload).encode() if payload is not None else None
    req=urllib.request.Request(BASE+path, data=data, headers=H, method=method)
    with urllib.request.urlopen(req, timeout=40) as r:
        return r.status, json.loads(r.read().decode())

def graphql(query, variables=None):
    payload={"query":query,"variables":variables or {}}
    st,res=api("POST","/graphql.json",payload)
    return res

_PUBS=None
def all_publication_ids():
    global _PUBS
    if _PUBS is None:
        res=graphql("{ publications(first:50){edges{node{id name}}} }")
        # 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 res.get("data",{}).get("publications",{}).get("edges",[])
               if e["node"]["id"]!="gid://shopify/Publication/29646651457"]
    return _PUBS

def publish_all_channels(product_id):
    gid=f"gid://shopify/Product/{product_id}"
    pubs=[{"publicationId":p} for p in all_publication_ids()]
    q="""mutation pub($id:ID!,$input:[PublicationInput!]!){ publishablePublish(id:$id, input:$input){ userErrors{message} } }"""
    res=graphql(q, {"id":gid,"input":pubs})
    errs=res.get("data",{}).get("publishablePublish",{}).get("userErrors",[])
    return len(pubs), errs

def title_case(s): return " ".join(w[:1].upper()+w[1:].lower() if w else w for w in (s or "").split())

def sku_exists(dw_sku):
    try:
        st,res=api("GET", f"/products.json?fields=id&limit=1&handle={urllib.parse.quote(handle_for(dw_sku))}")
        return False  # handle-based check is weak; rely on registry/dedup upstream
    except Exception:
        return False

def handle_for(rec):
    p=title_case(rec["pattern"]).lower().replace(" ","-"); c=(rec["color"] or "").lower().replace(" ","-")
    return f"{p}{('-'+c) if c else ''}-grasscloth-wallcovering-fentucci"

def metafields(rec):
    mf=[]
    def add(ns,key,val):
        if val not in (None,"",): mf.append({"namespace":ns,"key":key,"value":str(val),"type":"single_line_text_field"})
    add("global","width", rec.get("width") or "36 Inches")
    add("global","length", rec.get("length") or "8 Yards")
    add("global","unit_of_measure","Priced Per Single Roll")
    add("global","Content","Natural Grasscloth")
    add("global","repeat","Random Match")
    add("global","Color-Way", title_case(rec.get("color")) if rec.get("color") else None)
    add("global","Brand","Fentucci")
    add("global","Collection","TWIL Naturals")
    add("custom","color", title_case(rec.get("color")) if rec.get("color") else None)
    add("custom","real_color_name", title_case(rec.get("color")) if rec.get("color") else None)
    add("custom","manufacturer_sku", rec.get("mfr_sku"))
    add("custom","pattern_name", title_case(rec.get("pattern")))
    add("dwc","manufacturer_sku", rec.get("mfr_sku"))
    add("dwc","pattern_name", title_case(rec.get("pattern")))
    add("dwc","color", title_case(rec.get("color")) if rec.get("color") else None)
    add("dwc","brand","Fentucci")
    return mf

INVENTORY=2026   # Steve: inventory 2026 for both variants

def build(rec):
    pat=title_case(rec["pattern"]) or (rec.get("mfr_sku") or "").strip()  # never blank: fall back to MFR SKU
    col=title_case(rec["color"]) if rec.get("color") else ""
    title=f"{pat}{(' '+col) if col else ''} Grasscloth Wallcovering | Fentucci".replace("Wallpaper","Wallcovering")
    imgs=[{"src":u} for u in [rec.get("img1"),rec.get("img2")] if u]
    price=rec.get("price") or None   # column O "DW Price" = sell price per single roll
    inv=dict(inventory_management="shopify", inventory_quantity=INVENTORY)
    roll={"option1":"Roll","sku":rec["dw_sku"], **inv}
    if price: roll["price"]=price
    sample={"option1":"Sample","sku":f"{rec['dw_sku']}-Sample","price":SAMPLE_PRICE, **inv}
    variants=[roll, sample]   # Roll FIRST so PDP shows the real price (DW theme convention)
    # ACTIVE only if it has a sell price; unpriced stay draft (can't sell unpriced)
    status="active" if price else "draft"
    tags=["Grasscloth","Natural Wallcovering","Fentucci","TWIL Naturals", f"color:{col}" if col else "", "display_variant", "Needs-Cost" if not price else ""]
    return {"product":{
        "title":title,"body_html":rec.get("description") or "",
        "vendor":"Fentucci","product_type":"Wallcovering","status":status,
        "published_scope":"global",
        "tags":", ".join(t for t in tags if t),
        "options":[{"name":"Size"}],   # MUST be "Size" for DW theme price/variant picker
        "images":imgs,"variants":variants,"metafields":metafields(rec),
    }}

def main():
    recs=json.load(open(DATA))
    print(f"{len(recs)} TWIL grasscloth products | mode: {'DRY-RUN' if DRY else 'LIVE CREATE (draft)'}")
    created=0; skipped=0
    for rec in recs:
        payload=build(rec)
        p=payload["product"]
        if DRY:
            print(f"  [dry] {rec['dw_sku']:14} {p['title'][:44]:44} | {p['status']:6} {len(p['variants'])}v inv{INVENTORY} {len(p['metafields'])}mf price={rec.get('price') or 'NONE'}")
            continue
        try:
            st,res=api("POST","/products.json",payload)
            pid=res.get("product",{}).get("id")
            msg=f"  created {rec['dw_sku']} -> {pid} ({p['status']})"
            if p["status"]=="active":
                n,errs=publish_all_channels(pid)
                msg+=f" | published to {n} channels" + (f" ERR:{errs}" if errs else "")
            print(msg); created+=1
            time.sleep(0.6)
        except Exception as e:
            body=getattr(e,'read',lambda:b'')() if hasattr(e,'read') else b''
            print(f"  ERROR {rec['dw_sku']}: {e} {body[:200]}")
    if DRY:
        active=sum(1 for r in recs if r.get('price')); draft=len(recs)-active
        print(f"\nDRY-RUN — would create {active} ACTIVE (all channels, inv {INVENTORY}) + {draft} DRAFT (no sheet price). Re-run with CONFIRM=1.")
    else:
        print(f"\nDONE — created {created} products. Active ones published to all sales channels with inventory {INVENTORY}.")

if __name__=="__main__":
    main()