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:
2026-01-13 20:35:46 +01:00
parent 78b14a4b00
commit ccfbbcb804
13 changed files with 2571 additions and 46 deletions

View File

@@ -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