119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
"""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"}
|