Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/ecommerce_agent/api/etsy_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession

from ecommerce_agent.api.dependencies import get_services
from ecommerce_agent.api.schemas import EtsyInventoryUpdateRequest, EtsyListingUpdateRequest
from ecommerce_agent.db.session import SessionFactory, get_session
from ecommerce_agent.services import ServiceContainer
from ecommerce_agent.services.oauth import (
ETSY_REQUIRED_SCOPES,
EtsyAuthenticatedHttpClient,
Expand All @@ -22,6 +25,7 @@

router = APIRouter(prefix="/etsy", tags=["etsy"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
ServicesDep = Annotated[ServiceContainer, Depends(get_services)]


def _oauth_settings(request: Request) -> tuple[Any, TokenCipher]:
Expand Down Expand Up @@ -154,3 +158,43 @@ async def disconnect_etsy_oauth(request: Request, session: SessionDep) -> dict[s
await disconnect_etsy(session)
await session.commit()
return {"connected": False}


@router.get("/listings")
async def list_etsy_shop_listings(
services: ServicesDep,
state: str = Query(default="draft", pattern="^(draft|active)$"),
) -> list[dict[str, Any]]:
return await services.etsy.list_shop_listings(state)


@router.get("/listings/{listing_id}")
async def get_etsy_listing(listing_id: str, services: ServicesDep) -> dict[str, Any]:
return await services.etsy.get_listing_detail(listing_id)


@router.patch("/listings/{listing_id}")
async def update_etsy_listing(
listing_id: str,
body: EtsyListingUpdateRequest,
services: ServicesDep,
) -> dict[str, Any]:
payload = body.model_dump(exclude_none=True)
return await services.etsy.update_listing(
listing_id,
payload,
idempotency_key=f"etsy-listing:{listing_id}:update",
)


@router.put("/listings/{listing_id}/inventory")
async def update_etsy_listing_inventory(
listing_id: str,
body: EtsyInventoryUpdateRequest,
services: ServicesDep,
) -> dict[str, Any]:
return await services.etsy.update_inventory(
listing_id,
body.model_dump(),
idempotency_key=f"etsy-listing:{listing_id}:inventory",
)
65 changes: 64 additions & 1 deletion src/ecommerce_agent/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from decimal import Decimal
from typing import Any

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator

from ecommerce_agent.domain.enums import ApprovalDecision, ApprovalType

Expand Down Expand Up @@ -89,6 +89,69 @@ class PrintfulAttachRequest(BaseModel):
product_id: uuid.UUID


class EtsyListingUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=140)
description: str | None = Field(default=None, max_length=50000)
tags: list[str] | None = None
materials: list[str] | None = None
taxonomy_id: int | None = Field(default=None, ge=1)
shipping_profile_id: int | None = Field(default=None, ge=1)
return_policy_id: int | None = Field(default=None, ge=1)
readiness_state_id: int | None = Field(default=None, ge=1)
who_made: str | None = None
when_made: str | None = None
is_supply: bool | None = None
should_auto_renew: bool | None = None
production_partner_ids: list[int] | None = None

@field_validator("title")
@classmethod
def title_is_not_blank(cls, value: str | None) -> str | None:
if value is not None and not value.strip():
raise ValueError("Title is required.")
return value

@field_validator("tags")
@classmethod
def valid_tags(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
tags = [tag.strip() for tag in value if tag.strip()]
if len(tags) > 13:
raise ValueError("Use at most 13 tags.")
if any(len(tag) > 20 for tag in tags):
raise ValueError("Each tag must be at most 20 characters.")
return tags

@field_validator("materials")
@classmethod
def valid_materials(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
materials = [material.strip() for material in value if material.strip()]
if any(len(material) > 45 for material in materials):
raise ValueError("Each material must be at most 45 characters.")
return materials


class EtsyInventoryUpdateRequest(BaseModel):
products: list[dict[str, Any]] = Field(min_length=1)
price_on_property: list[int] = Field(default_factory=list)
quantity_on_property: list[int] = Field(default_factory=list)
sku_on_property: list[int] = Field(default_factory=list)
readiness_state_on_property: list[int] = Field(default_factory=list)

@field_validator("products")
@classmethod
def products_have_positive_quantities(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
for product in value:
for offering in product.get("offerings", []):
quantity = offering.get("quantity")
if quantity is not None and int(quantity) < 1:
raise ValueError("Quantity must be positive.")
return value


class PostingSessionCreateRequest(BaseModel):
name: str = Field(default="Etsy posting session", min_length=1, max_length=255)
template_ids: list[int] = Field(min_length=1)
Expand Down
Loading
Loading