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:
parent
a794f4732a
commit
e5317dab27
|
|
@ -15,6 +15,7 @@ JWT_SECRET=generate_a_strong_secret_here
|
||||||
|
|
||||||
# App
|
# App
|
||||||
CORS_ORIGINS=https://rswag.online
|
CORS_ORIGINS=https://rswag.online
|
||||||
|
PUBLIC_URL=https://rswag.online
|
||||||
|
|
||||||
# AI Design Generation
|
# AI Design Generation
|
||||||
GEMINI_API_KEY=xxx
|
GEMINI_API_KEY=xxx
|
||||||
|
|
|
||||||
|
|
@ -152,22 +152,30 @@ async def _get_printful_mockup(slug: str, product) -> bytes | None:
|
||||||
variant_ids = [variants[0]["id"]]
|
variant_ids = [variants[0]["id"]]
|
||||||
|
|
||||||
# Public image URL for Printful to download
|
# 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)
|
# Generate mockup (blocks up to ~60s on first call)
|
||||||
mockups = await printful.generate_mockup_and_wait(
|
mockups = await printful.generate_mockup_and_wait(
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
variant_ids=variant_ids,
|
variant_ids=variant_ids,
|
||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
|
placement="front",
|
||||||
|
technique="dtg",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not mockups:
|
if not mockups:
|
||||||
return None
|
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
|
mockup_url = None
|
||||||
for m in mockups:
|
for m in mockups:
|
||||||
mockup_url = m.get("mockup_url") or m.get("url")
|
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:
|
if mockup_url:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# App
|
# App
|
||||||
app_name: str = "rSwag"
|
app_name: str = "rSwag"
|
||||||
|
public_url: str = "https://rswag.online"
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -98,22 +98,37 @@ class PrintfulClient:
|
||||||
product_id: int,
|
product_id: int,
|
||||||
variant_ids: list[int],
|
variant_ids: list[int],
|
||||||
image_url: str,
|
image_url: str,
|
||||||
placement: str = "front_large",
|
placement: str = "front",
|
||||||
|
technique: str = "dtg",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start async mockup generation task.
|
"""Start async mockup generation task (v2 format).
|
||||||
|
|
||||||
Returns task_id to poll with get_mockup_task().
|
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 = {
|
payload = {
|
||||||
"product_id": product_id,
|
"products": [
|
||||||
"variant_ids": variant_ids,
|
|
||||||
"format": "png",
|
|
||||||
"placements": [
|
|
||||||
{
|
{
|
||||||
"placement": placement,
|
"source": "catalog",
|
||||||
"image_url": image_url,
|
"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:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
|
@ -124,21 +139,20 @@ class PrintfulClient:
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json().get("data", {})
|
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}")
|
logger.info(f"Printful mockup task created: {task_id}")
|
||||||
return str(task_id)
|
return str(task_id)
|
||||||
|
|
||||||
async def get_mockup_task(self, task_id: str) -> dict:
|
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
|
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:
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{BASE_URL}/mockup-tasks",
|
f"{BASE_URL}/mockup-tasks/{task_id}",
|
||||||
headers=self._headers,
|
headers=self._headers,
|
||||||
params={"task_key": task_id},
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json().get("data", {})
|
return resp.json().get("data", {})
|
||||||
|
|
@ -148,7 +162,8 @@ class PrintfulClient:
|
||||||
product_id: int,
|
product_id: int,
|
||||||
variant_ids: list[int],
|
variant_ids: list[int],
|
||||||
image_url: str,
|
image_url: str,
|
||||||
placement: str = "front_large",
|
placement: str = "front",
|
||||||
|
technique: str = "dtg",
|
||||||
max_polls: int = 20,
|
max_polls: int = 20,
|
||||||
poll_interval: float = 3.0,
|
poll_interval: float = 3.0,
|
||||||
) -> list[dict] | None:
|
) -> list[dict] | None:
|
||||||
|
|
@ -158,7 +173,7 @@ class PrintfulClient:
|
||||||
or None on failure/timeout.
|
or None on failure/timeout.
|
||||||
"""
|
"""
|
||||||
task_id = await self.create_mockup_task(
|
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):
|
for _ in range(max_polls):
|
||||||
|
|
@ -193,7 +208,7 @@ class PrintfulClient:
|
||||||
- catalog_variant_id (int)
|
- catalog_variant_id (int)
|
||||||
- quantity (int)
|
- quantity (int)
|
||||||
- image_url (str) — public URL to design
|
- image_url (str) — public URL to design
|
||||||
- placement (str, default "front_large")
|
- placement (str, default "front")
|
||||||
recipient: dict with name, address1, city, state_code,
|
recipient: dict with name, address1, city, state_code,
|
||||||
country_code, zip, email (optional)
|
country_code, zip, email (optional)
|
||||||
"""
|
"""
|
||||||
|
|
@ -208,7 +223,7 @@ class PrintfulClient:
|
||||||
"quantity": item.get("quantity", 1),
|
"quantity": item.get("quantity", 1),
|
||||||
"placements": [
|
"placements": [
|
||||||
{
|
{
|
||||||
"placement": item.get("placement", "front_large"),
|
"placement": item.get("placement", "front"),
|
||||||
"technique": "dtg",
|
"technique": "dtg",
|
||||||
"layers": [
|
"layers": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
---
|
---
|
||||||
id: TASK-5
|
id: TASK-5
|
||||||
title: Add real Printful mockup API integration
|
title: Add real Printful mockup API integration
|
||||||
status: To Do
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-02-18 19:51'
|
created_date: '2026-02-18 19:51'
|
||||||
|
updated_date: '2026-02-21 20:54'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: low
|
priority: high
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
@ -14,3 +15,23 @@ priority: low
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- 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.
|
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 -->
|
<!-- 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 -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue