From 54d064f3fa93db665338a4c74b4fcfa20fab4c30 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 14:00:40 -0700 Subject: [PATCH] feat: add full Printful API v2 integration (mockups + fulfillment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrintfulClient: catalog variants (cached 24h), mockup generation (async create+poll), order submission, variant ID resolution - Mockup endpoint tries Printful API first for Printful-provider designs, falls back to Pillow compositing for others (e.g. Prodigi stickers) - Order service routes items by provider from design metadata: provider=printful → Printful API, provider=prodigi → Prodigi API - Sandbox mode creates draft orders on Printful (not fulfilled) Co-Authored-By: Claude Opus 4.6 --- backend/app/api/designs.py | 106 ++++++++++- backend/app/pod/printful_client.py | 248 ++++++++++++++++++++++++++ backend/app/services/order_service.py | 142 ++++++++++++--- 3 files changed, 466 insertions(+), 30 deletions(-) create mode 100644 backend/app/pod/printful_client.py diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py index b3f421e..5d3a0a7 100644 --- a/backend/app/api/designs.py +++ b/backend/app/api/designs.py @@ -1,8 +1,10 @@ """Designs API endpoints.""" import io +import logging from pathlib import Path +import httpx from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, StreamingResponse from PIL import Image @@ -11,6 +13,7 @@ from app.config import get_settings from app.schemas.design import Design from app.services.design_service import DesignService +logger = logging.getLogger(__name__) router = APIRouter() design_service = DesignService() settings = get_settings() @@ -31,6 +34,13 @@ MOCKUP_TEMPLATES = { }, } +# Map mockup type → matching product types from metadata +_TYPE_MAP = { + "shirt": ("shirt", "tshirt", "tee", "hoodie"), + "sticker": ("sticker",), + "print": ("print",), +} + # Cache generated mockups in memory: (slug, product_type) → PNG bytes _mockup_cache: dict[tuple[str, str], bytes] = {} @@ -66,7 +76,7 @@ async def get_design_image(slug: str): image_path, media_type="image/png", headers={ - "Cache-Control": "public, max-age=86400", # Cache for 24 hours + "Cache-Control": "public, max-age=86400", }, ) @@ -75,8 +85,9 @@ async def get_design_image(slug: str): async def get_design_mockup(slug: str, type: str = "shirt"): """Serve the design composited onto a product mockup template. - Composites the design image onto a product template (shirt, sticker, print) - using Pillow. Result is cached in memory for fast subsequent requests. + For Printful-provider designs: fetches photorealistic mockup from + Printful's mockup generator API (cached after first generation). + For other designs: composites with Pillow using local templates. Query params: type: Product type — "shirt", "sticker", or "print" (default: shirt) @@ -89,20 +100,100 @@ async def get_design_mockup(slug: str, type: str = "shirt"): headers={"Cache-Control": "public, max-age=86400"}, ) + # Load design to check provider + design = await design_service.get_design(slug) + if not design: + raise HTTPException(status_code=404, detail="Design not found") + + # Find a Printful-provider product matching the requested mockup type + printful_product = None + accepted_types = _TYPE_MAP.get(type, (type,)) + for p in design.products: + if p.provider == "printful" and p.type in accepted_types: + printful_product = p + break + + # Try Printful mockup API for Printful-provider designs + if printful_product and settings.printful_api_token: + png_bytes = await _get_printful_mockup(slug, printful_product) + if png_bytes: + _mockup_cache[cache_key] = png_bytes + return StreamingResponse( + io.BytesIO(png_bytes), + media_type="image/png", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + # Fallback: Pillow compositing with local templates + return await _pillow_mockup(slug, type) + + +async def _get_printful_mockup(slug: str, product) -> bytes | None: + """Fetch mockup from Printful API. Returns PNG bytes or None.""" + from app.pod.printful_client import PrintfulClient + + printful = PrintfulClient() + if not printful.enabled: + return None + + try: + product_id = int(product.sku) + + # Get first variant for mockup preview + variants = await printful.get_catalog_variants(product_id) + if not variants: + logger.warning(f"No Printful variants for product {product_id}") + return None + variant_ids = [variants[0]["id"]] + + # Public image URL for Printful to download + image_url = f"https://fungiswag.jeffemmett.com/api/designs/{slug}/image" + + # Generate mockup (blocks up to ~60s on first call) + mockups = await printful.generate_mockup_and_wait( + product_id=product_id, + variant_ids=variant_ids, + image_url=image_url, + ) + + if not mockups: + return None + + # Find a mockup URL from the result + mockup_url = None + for m in mockups: + mockup_url = m.get("mockup_url") or m.get("url") + if mockup_url: + break + + if not mockup_url: + logger.warning(f"No mockup URL in Printful response for {slug}") + return None + + # Download the mockup image + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(mockup_url) + resp.raise_for_status() + return resp.content + + except Exception as e: + logger.warning(f"Printful mockup failed for {slug}: {e}") + return None + + +async def _pillow_mockup(slug: str, type: str) -> StreamingResponse: + """Generate mockup using Pillow compositing with local templates.""" template_config = MOCKUP_TEMPLATES.get(type) if not template_config: raise HTTPException(status_code=400, detail=f"Unknown product type: {type}") - # Load design image image_path = await design_service.get_design_image_path(slug) if not image_path or not Path(image_path).exists(): raise HTTPException(status_code=404, detail="Design image not found") - # Load template image from frontend/public/mockups/ + # Load template from frontend/public/mockups/ or /app/mockups/ (Docker mount) template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups" template_path = template_dir / template_config["template"] - - # Fallback: check if templates are mounted at /app/frontend/public/mockups/ if not template_path.exists(): template_path = Path("/app/mockups") / template_config["template"] if not template_path.exists(): @@ -133,6 +224,7 @@ async def get_design_mockup(slug: str, type: str = "shirt"): png_bytes = buf.getvalue() # Cache the result + cache_key = (slug, type) _mockup_cache[cache_key] = png_bytes return StreamingResponse( diff --git a/backend/app/pod/printful_client.py b/backend/app/pod/printful_client.py new file mode 100644 index 0000000..40531c3 --- /dev/null +++ b/backend/app/pod/printful_client.py @@ -0,0 +1,248 @@ +"""Printful Print-on-Demand API client (v2). + +Handles catalog lookup, mockup generation, and order submission. +API v2 docs: https://developers.printful.com/docs/v2-beta/ +Rate limit: 120 req/60s (leaky bucket), lower for mockups. +""" + +import asyncio +import logging +import time + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +BASE_URL = "https://api.printful.com/v2" + +# In-memory cache for catalog variants: {product_id: {"variants": [...], "ts": float}} +_variant_cache: dict[int, dict] = {} +_VARIANT_CACHE_TTL = 86400 # 24 hours + + +class PrintfulClient: + """Client for the Printful v2 API.""" + + def __init__(self): + self.api_token = settings.printful_api_token + self.sandbox = settings.pod_sandbox_mode + self.enabled = bool(self.api_token) + + @property + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + + # ── Catalog ── + + async def get_catalog_variants(self, product_id: int) -> list[dict]: + """Get variants for a catalog product (cached 24h). + + Each variant has: id (int), size (str), color (str), color_code (str). + """ + cached = _variant_cache.get(product_id) + if cached and (time.time() - cached["ts"]) < _VARIANT_CACHE_TTL: + return cached["variants"] + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{BASE_URL}/catalog-products/{product_id}/catalog-variants", + headers=self._headers, + ) + resp.raise_for_status() + variants = resp.json().get("data", []) + + _variant_cache[product_id] = {"variants": variants, "ts": time.time()} + return variants + + async def resolve_variant_id( + self, + product_id: int, + size: str, + color: str = "Black", + ) -> int | None: + """Resolve (product_id, size, color) → Printful catalog_variant_id. + + Our metadata uses SKU "71" + variants ["S","M","L",...]. + Printful orders require numeric catalog_variant_id. + """ + variants = await self.get_catalog_variants(product_id) + + # Try exact match on size + color + for v in variants: + if ( + v.get("size", "").upper() == size.upper() + and color.lower() in v.get("color", "").lower() + ): + return v.get("id") + + # Fallback: match size only + for v in variants: + if v.get("size", "").upper() == size.upper(): + return v.get("id") + + return None + + # ── Mockup Generation ── + + async def create_mockup_task( + self, + product_id: int, + variant_ids: list[int], + image_url: str, + placement: str = "front_large", + ) -> str: + """Start async mockup generation task. + + Returns task_id to poll with get_mockup_task(). + """ + payload = { + "product_id": product_id, + "variant_ids": variant_ids, + "format": "png", + "placements": [ + { + "placement": placement, + "image_url": image_url, + } + ], + } + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{BASE_URL}/mockup-tasks", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + data = resp.json().get("data", {}) + task_id = data.get("task_key") or data.get("id") or data.get("task_id") + logger.info(f"Printful mockup task created: {task_id}") + return str(task_id) + + async def get_mockup_task(self, task_id: str) -> dict: + """Poll mockup task status. + + Returns dict with "status" (pending/completed/failed) and + "mockups" list when completed. + """ + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{BASE_URL}/mockup-tasks", + headers=self._headers, + params={"task_key": task_id}, + ) + resp.raise_for_status() + return resp.json().get("data", {}) + + async def generate_mockup_and_wait( + self, + product_id: int, + variant_ids: list[int], + image_url: str, + placement: str = "front_large", + max_polls: int = 20, + poll_interval: float = 3.0, + ) -> list[dict] | None: + """Create mockup task and poll until complete. + + Returns list of mockup dicts with "mockup_url" fields, + or None on failure/timeout. + """ + task_id = await self.create_mockup_task( + product_id, variant_ids, image_url, placement + ) + + for _ in range(max_polls): + await asyncio.sleep(poll_interval) + result = await self.get_mockup_task(task_id) + status = result.get("status", "") + + if status == "completed": + return ( + result.get("mockups", []) + or result.get("catalog_variant_mockups", []) + ) + elif status == "failed": + reasons = result.get("failure_reasons", []) + logger.error(f"Mockup task {task_id} failed: {reasons}") + return None + + logger.warning(f"Mockup task {task_id} timed out after {max_polls} polls") + return None + + # ── Orders ── + + async def create_order( + self, + items: list[dict], + recipient: dict, + ) -> dict: + """Create a fulfillment order. + + Args: + items: List of dicts with: + - catalog_variant_id (int) + - quantity (int) + - image_url (str) — public URL to design + - placement (str, default "front_large") + recipient: dict with name, address1, city, state_code, + country_code, zip, email (optional) + """ + if not self.enabled: + raise ValueError("Printful API token not configured") + + order_items = [] + for item in items: + order_items.append({ + "source": "catalog", + "catalog_variant_id": item["catalog_variant_id"], + "quantity": item.get("quantity", 1), + "placements": [ + { + "placement": item.get("placement", "front_large"), + "technique": "dtg", + "layers": [ + { + "type": "file", + "url": item["image_url"], + } + ], + } + ], + }) + + payload = { + "recipient": recipient, + "items": order_items, + } + + # Sandbox mode: create as draft (not sent to production) + if self.sandbox: + payload["draft"] = True + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{BASE_URL}/orders", + headers=self._headers, + json=payload, + ) + resp.raise_for_status() + result = resp.json().get("data", {}) + logger.info(f"Printful order created: {result.get('id')}") + return result + + async def get_order(self, order_id: str) -> dict: + """Get order details by Printful order ID.""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + f"{BASE_URL}/orders/{order_id}", + headers=self._headers, + ) + resp.raise_for_status() + return resp.json().get("data", {}) diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index dfd2a40..fd91d96 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -14,7 +14,9 @@ from app.models.customer import Customer from app.models.cart import Cart from app.schemas.order import OrderResponse, OrderItemResponse from app.services.flow_service import FlowService +from app.pod.printful_client import PrintfulClient from app.pod.prodigi_client import ProdigiClient +from app.services.design_service import DesignService logger = logging.getLogger(__name__) settings = get_settings() @@ -181,38 +183,133 @@ class OrderService: await self.db.commit() async def _submit_to_pod(self, order: Order): - """Submit order items to Prodigi for fulfillment. + """Route order items to the correct POD provider for fulfillment. - Groups items by POD provider and submits orders. - Design images are served via public URL for Prodigi to download. + Reads each item's design metadata to determine provider (printful/prodigi), + groups items, and submits separate orders per provider. """ - prodigi = ProdigiClient() - if not prodigi.enabled: - logger.info("Prodigi not configured, skipping POD submission") - return - - # Need shipping address for POD — skip if not available if not order.shipping_address_line1: logger.info(f"Order {order.id} has no shipping address, skipping POD") return - # Collect Prodigi items from order + design_service = DesignService() + printful_items = [] prodigi_items = [] + for item in order.items: - # Build public image URL for Prodigi to download - # TODO: Use CDN URL in production; for now use the API endpoint image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image" - prodigi_items.append({ - "sku": item.variant or item.product_slug, - "copies": item.quantity, - "sizing": "fillPrintArea", - "assets": [{"printArea": "default", "url": image_url}], + design = await design_service.get_design(item.product_slug) + provider = "prodigi" # default + product_sku = item.variant or item.product_slug + + if design and design.products: + product_config = design.products[0] + provider = product_config.provider + product_sku = product_config.sku + + if provider == "printful": + # Extract size from variant string (e.g. "71-M" → "M", or just "M") + size = item.variant or "M" + if "-" in size: + size = size.split("-", 1)[1] + + printful_items.append({ + "order_item": item, + "product_id": int(product_sku), + "size": size, + "quantity": item.quantity, + "image_url": image_url, + }) + else: + prodigi_items.append({ + "order_item": item, + "sku": item.variant or item.product_slug, + "quantity": item.quantity, + "image_url": image_url, + }) + + if printful_items: + await self._submit_to_printful(order, printful_items) + if prodigi_items: + await self._submit_to_prodigi(order, prodigi_items) + + async def _submit_to_printful(self, order: Order, items: list[dict]): + """Submit items to Printful for fulfillment.""" + printful = PrintfulClient() + if not printful.enabled: + logger.info("Printful not configured, skipping") + return + + order_items = [] + for item_data in items: + variant_id = await printful.resolve_variant_id( + product_id=item_data["product_id"], + size=item_data["size"], + ) + if not variant_id: + logger.error( + f"Could not resolve Printful variant for product " + f"{item_data['product_id']} size {item_data['size']}" + ) + continue + + order_items.append({ + "catalog_variant_id": variant_id, + "quantity": item_data["quantity"], + "image_url": item_data["image_url"], + "placement": "front_large", }) - if not prodigi_items: + if not order_items: return + recipient = { + "name": order.shipping_name or "", + "address1": order.shipping_address_line1 or "", + "address2": order.shipping_address_line2 or "", + "city": order.shipping_city or "", + "state_code": order.shipping_state or "", + "country_code": order.shipping_country or "", + "zip": order.shipping_postal_code or "", + "email": order.shipping_email or "", + } + + try: + result = await printful.create_order( + items=order_items, + recipient=recipient, + ) + pod_order_id = str(result.get("id", "")) + + for item_data in items: + item_data["order_item"].pod_provider = "printful" + item_data["order_item"].pod_order_id = pod_order_id + item_data["order_item"].pod_status = "submitted" + + order.status = OrderStatus.PROCESSING.value + await self.db.commit() + logger.info(f"Submitted order {order.id} to Printful: {pod_order_id}") + + except Exception as e: + logger.error(f"Failed to submit order {order.id} to Printful: {e}") + + async def _submit_to_prodigi(self, order: Order, items: list[dict]): + """Submit items to Prodigi for fulfillment.""" + prodigi = ProdigiClient() + if not prodigi.enabled: + logger.info("Prodigi not configured, skipping") + return + + prodigi_items = [] + for item_data in items: + prodigi_items.append({ + "sku": item_data["sku"], + "copies": item_data["quantity"], + "sizing": "fillPrintArea", + "assets": [{"printArea": "default", "url": item_data["image_url"]}], + }) + recipient = { "name": order.shipping_name or "", "email": order.shipping_email or "", @@ -234,11 +331,10 @@ class OrderService: ) pod_order_id = result.get("id") - # Update order items with Prodigi order ID - for item in order.items: - item.pod_provider = "prodigi" - item.pod_order_id = pod_order_id - item.pod_status = "submitted" + for item_data in items: + item_data["order_item"].pod_provider = "prodigi" + item_data["order_item"].pod_order_id = pod_order_id + item_data["order_item"].pod_status = "submitted" order.status = OrderStatus.PROCESSING.value await self.db.commit()