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:
parent
acfe6cc350
commit
54d064f3fa
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue