feat: add full Printful API v2 integration (mockups + fulfillment)

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 14:00:40 -07:00
parent acfe6cc350
commit 54d064f3fa
3 changed files with 466 additions and 30 deletions

View File

@ -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(

View File

@ -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", {})

View File

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