Files
orion/app/services/letzshop/client_service.py
Samir Boulahtit ccfbbcb804 feat: add Letzshop vendor directory with sync and admin management
- 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>
2026-01-13 20:35:46 +01:00

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