feat: update Printful client to v2 mockup-tasks API format

The v2 API uses products array with catalog source, nested layers in
placements, and GET /mockup-tasks/{id} for polling. Also removes
hardcoded domain in favor of PUBLIC_URL setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 16:57:04 -08:00
parent a794f4732a
commit e5317dab27
5 changed files with 67 additions and 21 deletions

View File

@ -15,6 +15,7 @@ JWT_SECRET=generate_a_strong_secret_here
# App
CORS_ORIGINS=https://rswag.online
PUBLIC_URL=https://rswag.online
# AI Design Generation
GEMINI_API_KEY=xxx

View File

@ -152,22 +152,30 @@ async def _get_printful_mockup(slug: str, product) -> bytes | None:
variant_ids = [variants[0]["id"]]
# Public image URL for Printful to download
image_url = f"https://fungiswag.jeffemmett.com/api/designs/{slug}/image"
image_url = f"{settings.public_url}/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,
placement="front",
technique="dtg",
)
if not mockups:
return None
# Find a mockup URL from the result
# v2 response: catalog_variant_mockups → each has mockup_url or
# placements[].mockup_url. Also check legacy "url" field.
mockup_url = None
for m in mockups:
mockup_url = m.get("mockup_url") or m.get("url")
if not mockup_url and "placements" in m:
for p in m["placements"]:
mockup_url = p.get("mockup_url") or p.get("url")
if mockup_url:
break
if mockup_url:
break

View File

@ -51,6 +51,7 @@ class Settings(BaseSettings):
# App
app_name: str = "rSwag"
public_url: str = "https://rswag.online"
debug: bool = False
@property

View File

@ -98,22 +98,37 @@ class PrintfulClient:
product_id: int,
variant_ids: list[int],
image_url: str,
placement: str = "front_large",
placement: str = "front",
technique: str = "dtg",
) -> str:
"""Start async mockup generation task.
"""Start async mockup generation task (v2 format).
Returns task_id to poll with get_mockup_task().
v2 payload uses products array with catalog source, and layers
inside placements instead of flat image_url.
"""
payload = {
"product_id": product_id,
"variant_ids": variant_ids,
"format": "png",
"placements": [
"products": [
{
"placement": placement,
"image_url": image_url,
"source": "catalog",
"catalog_product_id": product_id,
"catalog_variant_ids": variant_ids,
"placements": [
{
"placement": placement,
"technique": technique,
"layers": [
{
"type": "file",
"url": image_url,
}
],
}
],
}
],
"format": "png",
}
async with httpx.AsyncClient(timeout=30.0) as client:
@ -124,21 +139,20 @@ class PrintfulClient:
)
resp.raise_for_status()
data = resp.json().get("data", {})
task_id = data.get("task_key") or data.get("id") or data.get("task_id")
task_id = data.get("id") or data.get("task_key") 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.
"""Poll mockup task status (v2 format).
Returns dict with "status" (pending/completed/failed) and
"mockups" list when completed.
"catalog_variant_mockups" list when completed.
"""
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"{BASE_URL}/mockup-tasks",
f"{BASE_URL}/mockup-tasks/{task_id}",
headers=self._headers,
params={"task_key": task_id},
)
resp.raise_for_status()
return resp.json().get("data", {})
@ -148,7 +162,8 @@ class PrintfulClient:
product_id: int,
variant_ids: list[int],
image_url: str,
placement: str = "front_large",
placement: str = "front",
technique: str = "dtg",
max_polls: int = 20,
poll_interval: float = 3.0,
) -> list[dict] | None:
@ -158,7 +173,7 @@ class PrintfulClient:
or None on failure/timeout.
"""
task_id = await self.create_mockup_task(
product_id, variant_ids, image_url, placement
product_id, variant_ids, image_url, placement, technique
)
for _ in range(max_polls):
@ -193,7 +208,7 @@ class PrintfulClient:
- catalog_variant_id (int)
- quantity (int)
- image_url (str) public URL to design
- placement (str, default "front_large")
- placement (str, default "front")
recipient: dict with name, address1, city, state_code,
country_code, zip, email (optional)
"""
@ -208,7 +223,7 @@ class PrintfulClient:
"quantity": item.get("quantity", 1),
"placements": [
{
"placement": item.get("placement", "front_large"),
"placement": item.get("placement", "front"),
"technique": "dtg",
"layers": [
{

View File

@ -1,12 +1,13 @@
---
id: TASK-5
title: Add real Printful mockup API integration
status: To Do
status: In Progress
assignee: []
created_date: '2026-02-18 19:51'
updated_date: '2026-02-21 20:54'
labels: []
dependencies: []
priority: low
priority: high
---
## Description
@ -14,3 +15,23 @@ priority: low
<!-- SECTION:DESCRIPTION:BEGIN -->
Current upload page uses client-side Canvas compositing with simple template images. When Printful API token is configured, enhance with real Printful Mockup Generator API (POST /mockup-generator/create-task) for photorealistic product previews showing actual garment colors and fabric texture.
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-02-21: Printful client code is DONE and deployed. Blocking issue: API token not scoped to store.
What's done:
- backend/app/pod/printful_client.py created (catalog, mockups, orders)
- designs.py updated (Printful mockup path + Pillow fallback)
- order_service.py refactored (provider-aware routing: printful vs prodigi)
- Token stored at ~/.secrets/printful_api_token and in Netcup .env
- Deployed to fungiswag.jeffemmett.com (Pillow fallback working)
Blocking:
- Token u5WU...R2d returns "This endpoint requires store_id" on mockup/order APIs
- Need to create a NEW token on developers.printful.com scoped to "Fungi Flows" store
- Select the store in the "Access" dropdown (not "Account (all stores)")
Once new token is set, just update ~/.secrets/printful_api_token and Netcup .env, rebuild, done.
<!-- SECTION:NOTES:END -->