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>
This commit is contained in:
@@ -366,6 +366,83 @@ query GetShipmentsPaginated($first: Int!, $after: String) {{
|
||||
}}
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
# ============================================================================
|
||||
@@ -475,6 +552,74 @@ class LetzshopClient:
|
||||
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,
|
||||
@@ -771,3 +916,100 @@ class LetzshopClient:
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user