- Add LetzshopVendorCache model to store cached vendor data from Letzshop API - Create LetzshopVendorSyncService for syncing vendor directory - Add Celery task for background vendor sync - Create admin page at /admin/letzshop/vendor-directory with: - Stats dashboard (total, claimed, unclaimed vendors) - Searchable/filterable vendor list - "Sync Now" button to trigger sync - Ability to create platform vendors from Letzshop cache - Add API endpoints for vendor directory management - Add Pydantic schemas for API responses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1016 lines
28 KiB
Python
1016 lines
28 KiB
Python
# app/services/letzshop/client_service.py
|
|
"""
|
|
GraphQL client for Letzshop marketplace API.
|
|
|
|
Handles authentication, request formatting, and error handling
|
|
for all Letzshop API operations.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any, Callable
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default API endpoint
|
|
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
|
|
|
|
|
|
class LetzshopClientError(Exception):
|
|
"""Base exception for Letzshop client errors."""
|
|
|
|
def __init__(self, message: str, response_data: dict | None = None):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.response_data = response_data
|
|
|
|
|
|
class LetzshopAuthError(LetzshopClientError):
|
|
"""Raised when authentication fails."""
|
|
|
|
|
|
class LetzshopAPIError(LetzshopClientError):
|
|
"""Raised when the API returns an error response."""
|
|
|
|
|
|
class LetzshopConnectionError(LetzshopClientError):
|
|
"""Raised when connection to the API fails."""
|
|
|
|
|
|
# ============================================================================
|
|
# GraphQL Queries
|
|
# ============================================================================
|
|
|
|
QUERY_SHIPMENTS_UNCONFIRMED = """
|
|
query {
|
|
shipments(state: unconfirmed) {
|
|
nodes {
|
|
id
|
|
number
|
|
state
|
|
order {
|
|
id
|
|
number
|
|
email
|
|
total
|
|
completedAt
|
|
locale
|
|
shipAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {
|
|
name { en fr de }
|
|
iso
|
|
}
|
|
}
|
|
billAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {
|
|
name { en fr de }
|
|
iso
|
|
}
|
|
}
|
|
}
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
variant {
|
|
id
|
|
sku
|
|
mpn
|
|
price
|
|
tradeId {
|
|
number
|
|
parser
|
|
}
|
|
product {
|
|
name {
|
|
en
|
|
fr
|
|
de
|
|
}
|
|
_brand {
|
|
... on Brand {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
data {
|
|
__typename
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
QUERY_SHIPMENTS_CONFIRMED = """
|
|
query {
|
|
shipments(state: confirmed) {
|
|
nodes {
|
|
id
|
|
number
|
|
state
|
|
order {
|
|
id
|
|
number
|
|
email
|
|
total
|
|
completedAt
|
|
locale
|
|
shipAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {
|
|
name { en fr de }
|
|
iso
|
|
}
|
|
}
|
|
billAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {
|
|
name { en fr de }
|
|
iso
|
|
}
|
|
}
|
|
}
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
variant {
|
|
id
|
|
sku
|
|
mpn
|
|
price
|
|
tradeId {
|
|
number
|
|
parser
|
|
}
|
|
product {
|
|
name {
|
|
en
|
|
fr
|
|
de
|
|
}
|
|
_brand {
|
|
... on Brand {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
data {
|
|
__typename
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
QUERY_SHIPMENT_BY_ID = """
|
|
query GetShipment($id: ID!) {
|
|
node(id: $id) {
|
|
... on Shipment {
|
|
id
|
|
number
|
|
state
|
|
order {
|
|
id
|
|
number
|
|
email
|
|
total
|
|
completedAt
|
|
locale
|
|
shipAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {
|
|
name { en fr de }
|
|
iso
|
|
}
|
|
}
|
|
billAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {
|
|
name { en fr de }
|
|
iso
|
|
}
|
|
}
|
|
}
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
variant {
|
|
id
|
|
sku
|
|
mpn
|
|
price
|
|
tradeId {
|
|
number
|
|
parser
|
|
}
|
|
product {
|
|
name {
|
|
en
|
|
fr
|
|
de
|
|
}
|
|
_brand {
|
|
... on Brand {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
data {
|
|
__typename
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
# ============================================================================
|
|
# Paginated Queries (for historical import)
|
|
# ============================================================================
|
|
|
|
# Note: Using string formatting for state since Letzshop has issues with enum variables
|
|
# Note: tracking field removed - causes 'demodulize' server error on some shipments
|
|
QUERY_SHIPMENTS_PAGINATED_TEMPLATE = """
|
|
query GetShipmentsPaginated($first: Int!, $after: String) {{
|
|
shipments(state: {state}, first: $first, after: $after) {{
|
|
pageInfo {{
|
|
hasNextPage
|
|
endCursor
|
|
}}
|
|
nodes {{
|
|
id
|
|
number
|
|
state
|
|
order {{
|
|
id
|
|
number
|
|
email
|
|
total
|
|
completedAt
|
|
locale
|
|
shipAddress {{
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {{
|
|
iso
|
|
}}
|
|
}}
|
|
billAddress {{
|
|
firstName
|
|
lastName
|
|
company
|
|
streetName
|
|
streetNumber
|
|
city
|
|
zipCode
|
|
phone
|
|
country {{
|
|
iso
|
|
}}
|
|
}}
|
|
}}
|
|
inventoryUnits {{
|
|
id
|
|
state
|
|
variant {{
|
|
id
|
|
sku
|
|
mpn
|
|
price
|
|
tradeId {{
|
|
number
|
|
parser
|
|
}}
|
|
product {{
|
|
name {{
|
|
en
|
|
fr
|
|
de
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
data {{
|
|
__typename
|
|
}}
|
|
}}
|
|
}}
|
|
}}
|
|
"""
|
|
|
|
# ============================================================================
|
|
# GraphQL Queries - Vendor Directory (Public)
|
|
# ============================================================================
|
|
|
|
QUERY_VENDORS_PAGINATED = """
|
|
query GetVendorsPaginated($first: Int!, $after: String) {
|
|
vendors(first: $first, after: $after) {
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
totalCount
|
|
nodes {
|
|
id
|
|
slug
|
|
name
|
|
active
|
|
companyName
|
|
legalName
|
|
email
|
|
phone
|
|
fax
|
|
homepage
|
|
description { en fr de }
|
|
location {
|
|
street
|
|
number
|
|
city
|
|
zipcode
|
|
country { iso }
|
|
}
|
|
lat
|
|
lng
|
|
vendorCategories { name { en fr de } }
|
|
backgroundImage { url }
|
|
socialMediaLinks { url }
|
|
openingHours { en fr de }
|
|
representative
|
|
representativeTitle
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
QUERY_VENDOR_BY_SLUG = """
|
|
query GetVendorBySlug($slug: String!) {
|
|
vendor(slug: $slug) {
|
|
id
|
|
slug
|
|
name
|
|
active
|
|
companyName
|
|
legalName
|
|
email
|
|
phone
|
|
fax
|
|
homepage
|
|
description { en fr de }
|
|
location {
|
|
street
|
|
number
|
|
city
|
|
zipcode
|
|
country { iso }
|
|
}
|
|
lat
|
|
lng
|
|
vendorCategories { name { en fr de } }
|
|
backgroundImage { url }
|
|
socialMediaLinks { url }
|
|
openingHours { en fr de }
|
|
representative
|
|
representativeTitle
|
|
}
|
|
}
|
|
"""
|
|
|
|
# ============================================================================
|
|
# GraphQL Mutations
|
|
# ============================================================================
|
|
|
|
MUTATION_CONFIRM_INVENTORY_UNITS = """
|
|
mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) {
|
|
confirmInventoryUnits(input: $input) {
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
}
|
|
errors {
|
|
id
|
|
code
|
|
message
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
MUTATION_REJECT_INVENTORY_UNITS = """
|
|
mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) {
|
|
returnInventoryUnits(input: $input) {
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
}
|
|
errors {
|
|
id
|
|
code
|
|
message
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
MUTATION_SET_SHIPMENT_TRACKING = """
|
|
mutation SetShipmentTracking($input: SetShipmentTrackingInput!) {
|
|
setShipmentTracking(input: $input) {
|
|
shipment {
|
|
id
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
}
|
|
errors {
|
|
code
|
|
message
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
class LetzshopClient:
|
|
"""
|
|
GraphQL client for Letzshop marketplace API.
|
|
|
|
Usage:
|
|
client = LetzshopClient(api_key="your-api-key")
|
|
shipments = client.get_shipments(state="unconfirmed")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
endpoint: str = DEFAULT_ENDPOINT,
|
|
timeout: int = 30,
|
|
):
|
|
"""
|
|
Initialize the Letzshop client.
|
|
|
|
Args:
|
|
api_key: The Letzshop API key (Bearer token).
|
|
endpoint: The GraphQL endpoint URL.
|
|
timeout: Request timeout in seconds.
|
|
"""
|
|
self.api_key = api_key
|
|
self.endpoint = endpoint
|
|
self.timeout = timeout
|
|
self._session: requests.Session | None = None
|
|
|
|
@property
|
|
def session(self) -> requests.Session:
|
|
"""Get or create a requests session."""
|
|
if self._session is None:
|
|
self._session = requests.Session()
|
|
self._session.headers.update(
|
|
{
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
)
|
|
return self._session
|
|
|
|
def close(self) -> None:
|
|
"""Close the HTTP session."""
|
|
if self._session is not None:
|
|
self._session.close()
|
|
self._session = None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
return False
|
|
|
|
def _execute_public(
|
|
self,
|
|
query: str,
|
|
variables: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Execute a GraphQL query without authentication (for public queries).
|
|
|
|
Args:
|
|
query: The GraphQL query string.
|
|
variables: Optional variables for the query.
|
|
|
|
Returns:
|
|
The response data from the API.
|
|
|
|
Raises:
|
|
LetzshopAPIError: If the API returns an error.
|
|
LetzshopConnectionError: If the request fails.
|
|
"""
|
|
payload = {"query": query}
|
|
if variables:
|
|
payload["variables"] = variables
|
|
|
|
logger.debug(f"Executing public GraphQL request to {self.endpoint}")
|
|
|
|
try:
|
|
# Use a simple request without Authorization header
|
|
response = requests.post(
|
|
self.endpoint,
|
|
json=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=self.timeout,
|
|
)
|
|
except requests.exceptions.Timeout as e:
|
|
raise LetzshopConnectionError(f"Request timed out: {e}") from e
|
|
except requests.exceptions.ConnectionError as e:
|
|
raise LetzshopConnectionError(f"Connection failed: {e}") from e
|
|
except requests.exceptions.RequestException as e:
|
|
raise LetzshopConnectionError(f"Request failed: {e}") from e
|
|
|
|
# Handle HTTP-level errors
|
|
if response.status_code >= 500:
|
|
raise LetzshopAPIError(
|
|
f"Letzshop server error (HTTP {response.status_code})",
|
|
response_data={"status_code": response.status_code},
|
|
)
|
|
|
|
# Parse JSON response
|
|
try:
|
|
data = response.json()
|
|
except ValueError as e:
|
|
raise LetzshopAPIError(
|
|
f"Invalid JSON response: {response.text[:200]}"
|
|
) from e
|
|
|
|
logger.debug(f"GraphQL response: {data}")
|
|
|
|
# Handle GraphQL errors
|
|
if "errors" in data:
|
|
errors = data["errors"]
|
|
error_messages = [e.get("message", str(e)) for e in errors]
|
|
raise LetzshopAPIError(
|
|
f"GraphQL errors: {'; '.join(error_messages)}",
|
|
response_data=data,
|
|
)
|
|
|
|
return data.get("data", {})
|
|
|
|
def _execute(
|
|
self,
|
|
query: str,
|
|
variables: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Execute a GraphQL query or mutation.
|
|
|
|
Args:
|
|
query: The GraphQL query or mutation string.
|
|
variables: Optional variables for the query.
|
|
|
|
Returns:
|
|
The response data from the API.
|
|
|
|
Raises:
|
|
LetzshopAuthError: If authentication fails.
|
|
LetzshopAPIError: If the API returns an error.
|
|
LetzshopConnectionError: If the request fails.
|
|
"""
|
|
payload = {"query": query}
|
|
if variables:
|
|
payload["variables"] = variables
|
|
|
|
logger.debug(f"Executing GraphQL request to {self.endpoint}")
|
|
|
|
try:
|
|
response = self.session.post(
|
|
self.endpoint,
|
|
json=payload,
|
|
timeout=self.timeout,
|
|
)
|
|
except requests.exceptions.Timeout as e:
|
|
raise LetzshopConnectionError(f"Request timed out: {e}") from e
|
|
except requests.exceptions.ConnectionError as e:
|
|
raise LetzshopConnectionError(f"Connection failed: {e}") from e
|
|
except requests.exceptions.RequestException as e:
|
|
raise LetzshopConnectionError(f"Request failed: {e}") from e
|
|
|
|
# Handle HTTP-level errors
|
|
if response.status_code == 401:
|
|
raise LetzshopAuthError(
|
|
"Authentication failed. Please check your API key.",
|
|
response_data={"status_code": 401},
|
|
)
|
|
if response.status_code == 403:
|
|
raise LetzshopAuthError(
|
|
"Access forbidden. Your API key may not have the required permissions.",
|
|
response_data={"status_code": 403},
|
|
)
|
|
if response.status_code >= 500:
|
|
raise LetzshopAPIError(
|
|
f"Letzshop server error (HTTP {response.status_code})",
|
|
response_data={"status_code": response.status_code},
|
|
)
|
|
|
|
# Parse JSON response
|
|
try:
|
|
data = response.json()
|
|
except ValueError as e:
|
|
raise LetzshopAPIError(
|
|
f"Invalid JSON response: {response.text[:200]}"
|
|
) from e
|
|
|
|
logger.debug(f"GraphQL response: {data}")
|
|
|
|
# Check for GraphQL errors
|
|
if "errors" in data and data["errors"]:
|
|
error_messages = [
|
|
err.get("message", "Unknown error") for err in data["errors"]
|
|
]
|
|
logger.warning(f"GraphQL errors received: {data['errors']}")
|
|
raise LetzshopAPIError(
|
|
f"GraphQL errors: {'; '.join(error_messages)}",
|
|
response_data=data,
|
|
)
|
|
|
|
return data.get("data", {})
|
|
|
|
# ========================================================================
|
|
# Connection Testing
|
|
# ========================================================================
|
|
|
|
def test_connection(self) -> tuple[bool, float, str | None]:
|
|
"""
|
|
Test the connection to Letzshop API.
|
|
|
|
Returns:
|
|
Tuple of (success, response_time_ms, error_message).
|
|
"""
|
|
test_query = """
|
|
query TestConnection {
|
|
__typename
|
|
}
|
|
"""
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
self._execute(test_query)
|
|
elapsed_ms = (time.time() - start_time) * 1000
|
|
return True, elapsed_ms, None
|
|
except LetzshopClientError as e:
|
|
elapsed_ms = (time.time() - start_time) * 1000
|
|
return False, elapsed_ms, str(e)
|
|
|
|
# ========================================================================
|
|
# Shipment Queries
|
|
# ========================================================================
|
|
|
|
def get_shipments(
|
|
self,
|
|
state: str = "unconfirmed",
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Get shipments from Letzshop.
|
|
|
|
Args:
|
|
state: State filter ("unconfirmed" or "confirmed").
|
|
|
|
Returns:
|
|
List of shipment data dictionaries.
|
|
"""
|
|
# Use pre-built queries with inline state values
|
|
# (Letzshop's GraphQL has issues with enum variables)
|
|
if state == "confirmed":
|
|
query = QUERY_SHIPMENTS_CONFIRMED
|
|
else:
|
|
query = QUERY_SHIPMENTS_UNCONFIRMED
|
|
|
|
logger.debug(f"Fetching shipments with state: {state}")
|
|
data = self._execute(query)
|
|
logger.debug(f"Shipments response data keys: {data.keys() if data else 'None'}")
|
|
shipments_data = data.get("shipments", {})
|
|
nodes = shipments_data.get("nodes", [])
|
|
logger.info(f"Got {len(nodes)} {state} shipments from Letzshop API")
|
|
return nodes
|
|
|
|
def get_unconfirmed_shipments(self) -> list[dict[str, Any]]:
|
|
"""Get all unconfirmed shipments."""
|
|
return self.get_shipments(state="unconfirmed")
|
|
|
|
def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None:
|
|
"""
|
|
Get a single shipment by its ID.
|
|
|
|
Args:
|
|
shipment_id: The Letzshop shipment ID.
|
|
|
|
Returns:
|
|
Shipment data or None if not found.
|
|
"""
|
|
data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id})
|
|
return data.get("node")
|
|
|
|
def get_all_shipments_paginated(
|
|
self,
|
|
state: str = "confirmed",
|
|
page_size: int = 50,
|
|
max_pages: int | None = None,
|
|
progress_callback: Callable[[int, int], None] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Fetch all shipments with pagination support.
|
|
|
|
Args:
|
|
state: State filter ("unconfirmed" or "confirmed").
|
|
page_size: Number of shipments per page (default 50).
|
|
max_pages: Maximum number of pages to fetch (None = all).
|
|
progress_callback: Optional callback(page, total_fetched) for progress updates.
|
|
|
|
Returns:
|
|
List of all shipment data dictionaries.
|
|
"""
|
|
query = QUERY_SHIPMENTS_PAGINATED_TEMPLATE.format(state=state)
|
|
all_shipments = []
|
|
cursor = None
|
|
page = 0
|
|
|
|
while True:
|
|
page += 1
|
|
variables = {"first": page_size}
|
|
if cursor:
|
|
variables["after"] = cursor
|
|
|
|
logger.info(f"Fetching {state} shipments page {page} (cursor: {cursor})")
|
|
|
|
try:
|
|
data = self._execute(query, variables)
|
|
except LetzshopAPIError as e:
|
|
# Log error but return what we have so far
|
|
logger.error(f"Error fetching page {page}: {e}")
|
|
break
|
|
|
|
shipments_data = data.get("shipments", {})
|
|
nodes = shipments_data.get("nodes", [])
|
|
page_info = shipments_data.get("pageInfo", {})
|
|
|
|
all_shipments.extend(nodes)
|
|
|
|
if progress_callback:
|
|
progress_callback(page, len(all_shipments))
|
|
|
|
logger.info(f"Page {page}: fetched {len(nodes)} shipments, total: {len(all_shipments)}")
|
|
|
|
# Check if there are more pages
|
|
if not page_info.get("hasNextPage"):
|
|
logger.info(f"Reached last page. Total shipments: {len(all_shipments)}")
|
|
break
|
|
|
|
cursor = page_info.get("endCursor")
|
|
|
|
# Check max pages limit
|
|
if max_pages and page >= max_pages:
|
|
logger.info(f"Reached max pages limit ({max_pages}). Total shipments: {len(all_shipments)}")
|
|
break
|
|
|
|
return all_shipments
|
|
|
|
# ========================================================================
|
|
# Fulfillment Mutations
|
|
# ========================================================================
|
|
|
|
def confirm_inventory_units(
|
|
self,
|
|
inventory_unit_ids: list[str],
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Confirm inventory units for fulfillment.
|
|
|
|
Args:
|
|
inventory_unit_ids: List of inventory unit IDs to confirm.
|
|
|
|
Returns:
|
|
Response data including confirmed units and any errors.
|
|
"""
|
|
variables = {
|
|
"input": {
|
|
"inventoryUnitIds": inventory_unit_ids,
|
|
}
|
|
}
|
|
|
|
data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables)
|
|
return data.get("confirmInventoryUnits", {})
|
|
|
|
def reject_inventory_units(
|
|
self,
|
|
inventory_unit_ids: list[str],
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Reject/return inventory units.
|
|
|
|
Args:
|
|
inventory_unit_ids: List of inventory unit IDs to reject.
|
|
|
|
Returns:
|
|
Response data including rejected units and any errors.
|
|
"""
|
|
variables = {
|
|
"input": {
|
|
"inventoryUnitIds": inventory_unit_ids,
|
|
}
|
|
}
|
|
|
|
data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables)
|
|
return data.get("returnInventoryUnits", {})
|
|
|
|
def set_shipment_tracking(
|
|
self,
|
|
shipment_id: str,
|
|
tracking_code: str,
|
|
tracking_provider: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Set tracking information for a shipment.
|
|
|
|
Args:
|
|
shipment_id: The Letzshop shipment ID.
|
|
tracking_code: The tracking number.
|
|
tracking_provider: The carrier code (e.g., "dhl", "ups").
|
|
|
|
Returns:
|
|
Response data including updated shipment and any errors.
|
|
"""
|
|
variables = {
|
|
"input": {
|
|
"shipmentId": shipment_id,
|
|
"tracking": {
|
|
"code": tracking_code,
|
|
"provider": tracking_provider,
|
|
},
|
|
}
|
|
}
|
|
|
|
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
|
|
return data.get("setShipmentTracking", {})
|
|
|
|
# ========================================================================
|
|
# Vendor Directory Queries (Public - No Auth Required)
|
|
# ========================================================================
|
|
|
|
def get_all_vendors_paginated(
|
|
self,
|
|
page_size: int = 50,
|
|
max_pages: int | None = None,
|
|
progress_callback: Callable[[int, int, int], None] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Fetch all vendors from Letzshop marketplace directory.
|
|
|
|
This uses the public GraphQL API (no authentication required).
|
|
|
|
Args:
|
|
page_size: Number of vendors per page (default 50).
|
|
max_pages: Maximum number of pages to fetch (None = all).
|
|
progress_callback: Optional callback(page, total_fetched, total_count)
|
|
for progress updates.
|
|
|
|
Returns:
|
|
List of all vendor data dictionaries.
|
|
"""
|
|
all_vendors = []
|
|
cursor = None
|
|
page = 0
|
|
total_count = None
|
|
|
|
while True:
|
|
page += 1
|
|
variables = {"first": page_size}
|
|
if cursor:
|
|
variables["after"] = cursor
|
|
|
|
logger.info(f"Fetching vendors page {page} (cursor: {cursor})")
|
|
|
|
try:
|
|
# Use public endpoint (no authentication required)
|
|
data = self._execute_public(QUERY_VENDORS_PAGINATED, variables)
|
|
except LetzshopAPIError as e:
|
|
logger.error(f"Error fetching vendors page {page}: {e}")
|
|
break
|
|
|
|
vendors_data = data.get("vendors", {})
|
|
nodes = vendors_data.get("nodes", [])
|
|
page_info = vendors_data.get("pageInfo", {})
|
|
|
|
if total_count is None:
|
|
total_count = vendors_data.get("totalCount", 0)
|
|
logger.info(f"Total vendors in Letzshop: {total_count}")
|
|
|
|
all_vendors.extend(nodes)
|
|
|
|
if progress_callback:
|
|
progress_callback(page, len(all_vendors), total_count)
|
|
|
|
logger.info(
|
|
f"Page {page}: fetched {len(nodes)} vendors, "
|
|
f"total: {len(all_vendors)}/{total_count}"
|
|
)
|
|
|
|
# Check if there are more pages
|
|
if not page_info.get("hasNextPage"):
|
|
logger.info(f"Reached last page. Total vendors: {len(all_vendors)}")
|
|
break
|
|
|
|
cursor = page_info.get("endCursor")
|
|
|
|
# Check max pages limit
|
|
if max_pages and page >= max_pages:
|
|
logger.info(
|
|
f"Reached max pages limit ({max_pages}). "
|
|
f"Total vendors: {len(all_vendors)}"
|
|
)
|
|
break
|
|
|
|
return all_vendors
|
|
|
|
def get_vendor_by_slug(self, slug: str) -> dict[str, Any] | None:
|
|
"""
|
|
Get a single vendor by their URL slug.
|
|
|
|
Args:
|
|
slug: The vendor's URL slug (e.g., "nicks-diecast-corner").
|
|
|
|
Returns:
|
|
Vendor data dictionary or None if not found.
|
|
"""
|
|
try:
|
|
# Use public endpoint (no authentication required)
|
|
data = self._execute_public(QUERY_VENDOR_BY_SLUG, {"slug": slug})
|
|
return data.get("vendor")
|
|
except LetzshopAPIError as e:
|
|
logger.warning(f"Vendor not found with slug '{slug}': {e}")
|
|
return None
|