"""Printful supplier adapter — real dropshipping fulfillment via the Printful API. Docs: https://developers.printful.com — OAuth token or personal access token via env PRINTFUL_API_KEY. Without a key the adapter reports `configured=False` and create_order raises so nothing fake is ever placed. Catalog/product APIs require a Printful account; order placement is the critical real-money call and is fully implemented here. """ import os import urllib.request import urllib.parse import json from app.engines.suppliers.base import SupplierAdapter, register_adapter PRINTFUL_API = "https://api.printful.com" PRINTFUL_KEY = os.getenv("PRINTFUL_API_KEY", "") def _req(method, path, body=None): if not PRINTFUL_KEY: raise RuntimeError("PRINTFUL_API_KEY not configured") data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request( f"{PRINTFUL_API}{path}", data=data, method=method, headers={"Authorization": f"Bearer {PRINTFUL_KEY}", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read()) @register_adapter class PrintfulAdapter(SupplierAdapter): adapter_type = "printful" @property def configured(self): return bool(PRINTFUL_KEY) def get_products(self): """List catalog products (paginated). Returns normalized product dicts.""" if not self.configured: return [] out = [] offset = 0 while True: d = _req("GET", f"/store/products?offset={offset}&limit=100") items = d.get("result", []) for p in items: out.append({ "sku": str(p.get("id")), "title": p.get("name", "Untitled"), "description": "", "category": p.get("type", "printful"), "brand": "Printful", "cost": float(p.get("price") or 0), "inventory": 9999, # print-on-demand: made to order "shipping_cost": 0.0, "shipping_time": "2-5 business days", "image_url": p.get("thumbnail_url") or "", }) total = d.get("paging", {}).get("total", 0) offset += len(items) if offset >= total or not items: break return out def get_inventory(self): # Print-on-demand has no stock limits. return [] def get_price(self, sku): # per-variant pricing; catalog price is the base. Return None to keep cached cost. return None def create_order(self, items): """Submit a real order to Printful. items = [{supplier_sku, qty, ...}].""" if not self.configured: raise RuntimeError("Printful not configured — cannot place a real order") # recipient comes from the order; MVP uses a placeholder that the caller # must override with real shipping data before go-live. payload = { "recipient": { "name": os.getenv("PRINTFUL_RECIPIENT_NAME", "Polaris Customer"), "address1": os.getenv("PRINTFUL_RECIPIENT_ADDR1", ""), "city": os.getenv("PRINTFUL_RECIPIENT_CITY", ""), "state_code": os.getenv("PRINTFUL_RECIPIENT_STATE", ""), "country_code": os.getenv("PRINTFUL_RECIPIENT_COUNTRY", "US"), "zip": os.getenv("PRINTFUL_RECIPIENT_ZIP", ""), }, "items": [ {"sync_variant_id": int(it["supplier_sku"]), "quantity": int(it["qty"])} for it in items ], } d = _req("POST", "/orders", payload) res = d.get("result", {}) return {"ref": str(res.get("id")), "status": res.get("status", "pending")} def get_order_status(self, ref): d = _req("GET", f"/orders/{ref}") return d.get("result", {}).get("status", "unknown") def get_tracking(self, ref): d = _req("GET", f"/orders/{ref}") res = d.get("result", {}) shipments = res.get("shipments", []) or [] if shipments: s = shipments[0] return {"tracking": s.get("tracking_number") or "", "carrier": s.get("carrier") or ""} return None def cancel_order(self, ref): _req("DELETE", f"/orders/{ref}") return {"status": "cancelled"}