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

@@ -7,6 +7,7 @@ Provides:
- Credential management service
- Order import service
- Fulfillment sync service
- Vendor directory sync service
"""
from .client_service import (
@@ -26,6 +27,10 @@ from .order_service import (
OrderNotFoundError,
VendorNotFoundError,
)
from .vendor_sync_service import (
LetzshopVendorSyncService,
get_vendor_sync_service,
)
__all__ = [
# Client
@@ -42,4 +47,7 @@ __all__ = [
"LetzshopOrderService",
"OrderNotFoundError",
"VendorNotFoundError",
# Vendor Sync Service
"LetzshopVendorSyncService",
"get_vendor_sync_service",
]

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

View File

@@ -0,0 +1,521 @@
# app/services/letzshop/vendor_sync_service.py
"""
Service for syncing Letzshop vendor directory to local cache.
Fetches vendor data from Letzshop's public GraphQL API and stores it
in the letzshop_vendor_cache table for fast lookups during signup.
"""
import logging
from datetime import UTC, datetime
from typing import Any, Callable
from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.services.letzshop.client_service import LetzshopClient
from models.database.letzshop import LetzshopVendorCache
logger = logging.getLogger(__name__)
class LetzshopVendorSyncService:
"""
Service for syncing Letzshop vendor directory.
Usage:
service = LetzshopVendorSyncService(db)
stats = service.sync_all_vendors()
"""
def __init__(self, db: Session):
"""Initialize the sync service."""
self.db = db
def sync_all_vendors(
self,
progress_callback: Callable[[int, int, int], None] | None = None,
max_pages: int | None = None,
) -> dict[str, Any]:
"""
Sync all vendors from Letzshop to local cache.
Args:
progress_callback: Optional callback(page, fetched, total) for progress.
Returns:
Dictionary with sync statistics.
"""
stats = {
"started_at": datetime.now(UTC),
"total_fetched": 0,
"created": 0,
"updated": 0,
"errors": 0,
"error_details": [],
}
logger.info("Starting Letzshop vendor directory sync...")
# Create client (no API key needed for public vendor data)
client = LetzshopClient(api_key="")
try:
# Fetch all vendors
vendors = client.get_all_vendors_paginated(
page_size=50,
max_pages=max_pages,
progress_callback=progress_callback,
)
stats["total_fetched"] = len(vendors)
logger.info(f"Fetched {len(vendors)} vendors from Letzshop")
# Process each vendor
for vendor_data in vendors:
try:
result = self._upsert_vendor(vendor_data)
if result == "created":
stats["created"] += 1
elif result == "updated":
stats["updated"] += 1
except Exception as e:
stats["errors"] += 1
error_info = {
"vendor_id": vendor_data.get("id"),
"slug": vendor_data.get("slug"),
"error": str(e),
}
stats["error_details"].append(error_info)
logger.error(f"Error processing vendor {vendor_data.get('slug')}: {e}")
# Commit all changes
self.db.commit()
logger.info(
f"Sync complete: {stats['created']} created, "
f"{stats['updated']} updated, {stats['errors']} errors"
)
except Exception as e:
self.db.rollback()
logger.error(f"Vendor sync failed: {e}")
stats["error"] = str(e)
raise
finally:
client.close()
stats["completed_at"] = datetime.now(UTC)
stats["duration_seconds"] = (
stats["completed_at"] - stats["started_at"]
).total_seconds()
return stats
def _upsert_vendor(self, vendor_data: dict[str, Any]) -> str:
"""
Insert or update a vendor in the cache.
Args:
vendor_data: Raw vendor data from Letzshop API.
Returns:
"created" or "updated" indicating the operation performed.
"""
letzshop_id = vendor_data.get("id")
slug = vendor_data.get("slug")
if not letzshop_id or not slug:
raise ValueError("Vendor missing required id or slug")
# Parse the vendor data
parsed = self._parse_vendor_data(vendor_data)
# Check if exists
existing = (
self.db.query(LetzshopVendorCache)
.filter(LetzshopVendorCache.letzshop_id == letzshop_id)
.first()
)
if existing:
# Update existing record (preserve claimed status)
for key, value in parsed.items():
if key not in ("claimed_by_vendor_id", "claimed_at"):
setattr(existing, key, value)
existing.last_synced_at = datetime.now(UTC)
return "updated"
else:
# Create new record
cache_entry = LetzshopVendorCache(
**parsed,
last_synced_at=datetime.now(UTC),
)
self.db.add(cache_entry)
return "created"
def _parse_vendor_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""
Parse raw Letzshop vendor data into cache model fields.
Args:
data: Raw vendor data from Letzshop API.
Returns:
Dictionary of parsed fields for LetzshopVendorCache.
"""
# Extract location
location = data.get("location") or {}
country = location.get("country") or {}
# Extract descriptions
description = data.get("description") or {}
# Extract opening hours
opening_hours = data.get("openingHours") or {}
# Extract categories (list of translated name objects)
categories = []
for cat in data.get("vendorCategories") or []:
cat_name = cat.get("name") or {}
# Prefer English, fallback to French or German
name = cat_name.get("en") or cat_name.get("fr") or cat_name.get("de")
if name:
categories.append(name)
# Extract social media URLs
social_links = []
for link in data.get("socialMediaLinks") or []:
url = link.get("url")
if url:
social_links.append(url)
# Extract background image
bg_image = data.get("backgroundImage") or {}
return {
"letzshop_id": data.get("id"),
"slug": data.get("slug"),
"name": data.get("name"),
"company_name": data.get("companyName") or data.get("legalName"),
"is_active": data.get("active", True),
# Descriptions
"description_en": description.get("en"),
"description_fr": description.get("fr"),
"description_de": description.get("de"),
# Contact
"email": data.get("email"),
"phone": data.get("phone"),
"fax": data.get("fax"),
"website": data.get("homepage"),
# Location
"street": location.get("street"),
"street_number": location.get("number"),
"city": location.get("city"),
"zipcode": location.get("zipcode"),
"country_iso": country.get("iso", "LU"),
"latitude": str(data.get("lat")) if data.get("lat") else None,
"longitude": str(data.get("lng")) if data.get("lng") else None,
# Categories and media
"categories": categories,
"background_image_url": bg_image.get("url"),
"social_media_links": social_links,
# Opening hours
"opening_hours_en": opening_hours.get("en"),
"opening_hours_fr": opening_hours.get("fr"),
"opening_hours_de": opening_hours.get("de"),
# Representative
"representative_name": data.get("representative"),
"representative_title": data.get("representativeTitle"),
# Raw data for reference
"raw_data": data,
}
def sync_single_vendor(self, slug: str) -> LetzshopVendorCache | None:
"""
Sync a single vendor by slug.
Useful for on-demand refresh when a user looks up a vendor.
Args:
slug: The vendor's URL slug.
Returns:
The updated/created cache entry, or None if not found.
"""
client = LetzshopClient(api_key="")
try:
vendor_data = client.get_vendor_by_slug(slug)
if not vendor_data:
logger.warning(f"Vendor not found on Letzshop: {slug}")
return None
result = self._upsert_vendor(vendor_data)
self.db.commit()
logger.info(f"Single vendor sync: {slug} ({result})")
return (
self.db.query(LetzshopVendorCache)
.filter(LetzshopVendorCache.slug == slug)
.first()
)
finally:
client.close()
def get_cached_vendor(self, slug: str) -> LetzshopVendorCache | None:
"""
Get a vendor from cache by slug.
Args:
slug: The vendor's URL slug.
Returns:
Cache entry or None if not found.
"""
return (
self.db.query(LetzshopVendorCache)
.filter(LetzshopVendorCache.slug == slug.lower())
.first()
)
def search_cached_vendors(
self,
search: str | None = None,
city: str | None = None,
category: str | None = None,
only_unclaimed: bool = False,
page: int = 1,
limit: int = 20,
) -> tuple[list[LetzshopVendorCache], int]:
"""
Search cached vendors with filters.
Args:
search: Search term for name.
city: Filter by city.
category: Filter by category.
only_unclaimed: Only return vendors not yet claimed.
page: Page number (1-indexed).
limit: Items per page.
Returns:
Tuple of (vendors list, total count).
"""
query = self.db.query(LetzshopVendorCache).filter(
LetzshopVendorCache.is_active == True # noqa: E712
)
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
func.lower(LetzshopVendorCache.name).like(search_term)
)
if city:
query = query.filter(
func.lower(LetzshopVendorCache.city) == city.lower()
)
if category:
# Search in JSON array
query = query.filter(
LetzshopVendorCache.categories.contains([category])
)
if only_unclaimed:
query = query.filter(
LetzshopVendorCache.claimed_by_vendor_id.is_(None)
)
# Get total count
total = query.count()
# Apply pagination
offset = (page - 1) * limit
vendors = (
query.order_by(LetzshopVendorCache.name)
.offset(offset)
.limit(limit)
.all()
)
return vendors, total
def get_sync_stats(self) -> dict[str, Any]:
"""
Get statistics about the vendor cache.
Returns:
Dictionary with cache statistics.
"""
total = self.db.query(LetzshopVendorCache).count()
active = (
self.db.query(LetzshopVendorCache)
.filter(LetzshopVendorCache.is_active == True) # noqa: E712
.count()
)
claimed = (
self.db.query(LetzshopVendorCache)
.filter(LetzshopVendorCache.claimed_by_vendor_id.isnot(None))
.count()
)
# Get last sync time
last_synced = (
self.db.query(func.max(LetzshopVendorCache.last_synced_at)).scalar()
)
# Get unique cities
cities = (
self.db.query(LetzshopVendorCache.city)
.filter(LetzshopVendorCache.city.isnot(None))
.distinct()
.count()
)
return {
"total_vendors": total,
"active_vendors": active,
"claimed_vendors": claimed,
"unclaimed_vendors": active - claimed,
"unique_cities": cities,
"last_synced_at": last_synced.isoformat() if last_synced else None,
}
def mark_vendor_claimed(
self,
letzshop_slug: str,
vendor_id: int,
) -> bool:
"""
Mark a Letzshop vendor as claimed by a platform vendor.
Args:
letzshop_slug: The Letzshop vendor slug.
vendor_id: The platform vendor ID that claimed it.
Returns:
True if successful, False if vendor not found.
"""
cache_entry = self.get_cached_vendor(letzshop_slug)
if not cache_entry:
return False
cache_entry.claimed_by_vendor_id = vendor_id
cache_entry.claimed_at = datetime.now(UTC)
self.db.commit()
logger.info(f"Vendor {letzshop_slug} claimed by vendor_id={vendor_id}")
return True
def create_vendor_from_cache(
self,
letzshop_slug: str,
company_id: int,
) -> dict[str, Any]:
"""
Create a platform vendor from a cached Letzshop vendor.
Args:
letzshop_slug: The Letzshop vendor slug.
company_id: The company ID to create the vendor under.
Returns:
Dictionary with created vendor info.
Raises:
ValueError: If vendor not found, already claimed, or company not found.
"""
import random
from sqlalchemy import func
from app.services.admin_service import admin_service
from models.database.company import Company
from models.database.vendor import Vendor
from models.schema.vendor import VendorCreate
# Get cache entry
cache_entry = self.get_cached_vendor(letzshop_slug)
if not cache_entry:
raise ValueError(f"Letzshop vendor '{letzshop_slug}' not found in cache")
if cache_entry.is_claimed:
raise ValueError(
f"Letzshop vendor '{cache_entry.name}' is already claimed "
f"by vendor ID {cache_entry.claimed_by_vendor_id}"
)
# Verify company exists
company = self.db.query(Company).filter(Company.id == company_id).first()
if not company:
raise ValueError(f"Company with ID {company_id} not found")
# Generate vendor code from slug
vendor_code = letzshop_slug.upper().replace("-", "_")[:20]
# Check if vendor code already exists
existing = (
self.db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code)
.first()
)
if existing:
vendor_code = f"{vendor_code[:16]}_{random.randint(100, 999)}"
# Generate subdomain from slug
subdomain = letzshop_slug.lower().replace("_", "-")[:30]
existing_subdomain = (
self.db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == subdomain)
.first()
)
if existing_subdomain:
subdomain = f"{subdomain[:26]}-{random.randint(100, 999)}"
# Create vendor data from cache
address = f"{cache_entry.street or ''} {cache_entry.street_number or ''}".strip()
vendor_data = VendorCreate(
name=cache_entry.name,
vendor_code=vendor_code,
subdomain=subdomain,
company_id=company_id,
email=cache_entry.email or company.email,
phone=cache_entry.phone,
description=cache_entry.description_en or cache_entry.description_fr or "",
city=cache_entry.city,
country=cache_entry.country_iso or "LU",
website=cache_entry.website,
address_line_1=address or None,
postal_code=cache_entry.zipcode,
)
# Create vendor
vendor = admin_service.create_vendor(self.db, vendor_data)
# Mark the Letzshop vendor as claimed (commits internally) # noqa: SVC-006
self.mark_vendor_claimed(letzshop_slug, vendor.id)
logger.info(
f"Created vendor {vendor.vendor_code} from Letzshop vendor {letzshop_slug}"
)
return {
"id": vendor.id,
"vendor_code": vendor.vendor_code,
"name": vendor.name,
"subdomain": vendor.subdomain,
"company_id": vendor.company_id,
}
# Singleton-style function for easy access
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
"""Get a vendor sync service instance."""
return LetzshopVendorSyncService(db)