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