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

@@ -27,6 +27,7 @@ from app.services.letzshop import (
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
LetzshopVendorSyncService,
OrderNotFoundError,
VendorNotFoundError,
)
@@ -34,8 +35,13 @@ from app.tasks.letzshop_tasks import process_historical_import
from models.database.user import User
from models.schema.letzshop import (
FulfillmentOperationResponse,
LetzshopCachedVendorDetail,
LetzshopCachedVendorDetailResponse,
LetzshopCachedVendorItem,
LetzshopCachedVendorListResponse,
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCreateVendorFromCacheResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsUpdate,
@@ -51,6 +57,9 @@ from models.schema.letzshop import (
LetzshopSuccessResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
LetzshopVendorDirectoryStats,
LetzshopVendorDirectoryStatsResponse,
LetzshopVendorDirectorySyncResponse,
LetzshopVendorListResponse,
LetzshopVendorOverview,
)
@@ -1272,3 +1281,239 @@ def sync_tracking_for_vendor(
message=f"Tracking sync failed: {e}",
errors=[str(e)],
)
# ============================================================================
# Vendor Directory (Letzshop Marketplace Vendors)
# ============================================================================
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
"""Get vendor sync service instance."""
return LetzshopVendorSyncService(db)
@router.post("/vendor-directory/sync")
def trigger_vendor_directory_sync(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Trigger a sync of the Letzshop vendor directory.
Fetches all vendors from Letzshop's public GraphQL API and updates
the local cache. This is typically run daily via Celery beat, but
can be triggered manually here.
"""
from app.tasks.celery_tasks.letzshop import sync_vendor_directory
# Try to dispatch via Celery first
try:
task = sync_vendor_directory.delay()
logger.info(
f"Admin {current_admin.email} triggered vendor directory sync (task={task.id})"
)
return {
"success": True,
"message": "Vendor directory sync started",
"task_id": task.id,
"mode": "celery",
}
except Exception as e:
# Fall back to background tasks
logger.warning(f"Celery dispatch failed, using background tasks: {e}")
def run_sync():
from app.core.database import SessionLocal
sync_db = SessionLocal()
try:
sync_service = LetzshopVendorSyncService(sync_db)
sync_service.sync_all_vendors()
finally:
sync_db.close()
background_tasks.add_task(run_sync)
logger.info(
f"Admin {current_admin.email} triggered vendor directory sync (background task)"
)
return {
"success": True,
"message": "Vendor directory sync started",
"mode": "background_task",
}
@router.get(
"/vendor-directory/stats",
response_model=LetzshopVendorDirectoryStatsResponse,
)
def get_vendor_directory_stats(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> LetzshopVendorDirectoryStatsResponse:
"""
Get statistics about the Letzshop vendor directory cache.
Returns total, active, claimed, and unclaimed vendor counts.
"""
sync_service = get_vendor_sync_service(db)
stats_data = sync_service.get_sync_stats()
return LetzshopVendorDirectoryStatsResponse(
stats=LetzshopVendorDirectoryStats(**stats_data)
)
@router.get(
"/vendor-directory/vendors",
response_model=LetzshopCachedVendorListResponse,
)
def list_cached_vendors(
search: str | None = Query(None, description="Search by name"),
city: str | None = Query(None, description="Filter by city"),
category: str | None = Query(None, description="Filter by category"),
only_unclaimed: bool = Query(False, description="Only show unclaimed vendors"),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> LetzshopCachedVendorListResponse:
"""
List cached Letzshop vendors with search and filtering.
This returns vendors from the local cache, not directly from Letzshop.
"""
sync_service = get_vendor_sync_service(db)
vendors, total = sync_service.search_cached_vendors(
search=search,
city=city,
category=category,
only_unclaimed=only_unclaimed,
page=page,
limit=limit,
)
return LetzshopCachedVendorListResponse(
vendors=[
LetzshopCachedVendorItem(
id=v.id,
letzshop_id=v.letzshop_id,
slug=v.slug,
name=v.name,
company_name=v.company_name,
email=v.email,
phone=v.phone,
website=v.website,
city=v.city,
categories=v.categories or [],
is_active=v.is_active,
is_claimed=v.is_claimed,
claimed_by_vendor_id=v.claimed_by_vendor_id,
last_synced_at=v.last_synced_at,
letzshop_url=v.letzshop_url,
)
for v in vendors
],
total=total,
page=page,
limit=limit,
has_more=(page * limit) < total,
)
@router.get(
"/vendor-directory/vendors/{slug}",
response_model=LetzshopCachedVendorDetailResponse,
)
def get_cached_vendor_detail(
slug: str = Path(..., description="Letzshop vendor slug"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> LetzshopCachedVendorDetailResponse:
"""
Get detailed information about a cached Letzshop vendor.
"""
sync_service = get_vendor_sync_service(db)
vendor = sync_service.get_cached_vendor(slug)
if not vendor:
raise ResourceNotFoundException("LetzshopVendor", slug)
return LetzshopCachedVendorDetailResponse(
vendor=LetzshopCachedVendorDetail(
id=vendor.id,
letzshop_id=vendor.letzshop_id,
slug=vendor.slug,
name=vendor.name,
company_name=vendor.company_name,
description_en=vendor.description_en,
description_fr=vendor.description_fr,
description_de=vendor.description_de,
email=vendor.email,
phone=vendor.phone,
fax=vendor.fax,
website=vendor.website,
street=vendor.street,
street_number=vendor.street_number,
city=vendor.city,
zipcode=vendor.zipcode,
country_iso=vendor.country_iso,
latitude=vendor.latitude,
longitude=vendor.longitude,
categories=vendor.categories or [],
background_image_url=vendor.background_image_url,
social_media_links=vendor.social_media_links or [],
opening_hours_en=vendor.opening_hours_en,
opening_hours_fr=vendor.opening_hours_fr,
opening_hours_de=vendor.opening_hours_de,
representative_name=vendor.representative_name,
representative_title=vendor.representative_title,
is_active=vendor.is_active,
is_claimed=vendor.is_claimed,
claimed_by_vendor_id=vendor.claimed_by_vendor_id,
claimed_at=vendor.claimed_at,
last_synced_at=vendor.last_synced_at,
letzshop_url=vendor.letzshop_url,
)
)
@router.post(
"/vendor-directory/vendors/{slug}/create-vendor",
response_model=LetzshopCreateVendorFromCacheResponse,
)
def create_vendor_from_letzshop(
slug: str = Path(..., description="Letzshop vendor slug"),
company_id: int = Query(..., description="Company ID to create vendor under"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
) -> LetzshopCreateVendorFromCacheResponse:
"""
Create a platform vendor from a cached Letzshop vendor.
This creates a new vendor on the platform using information from the
Letzshop vendor cache. The vendor will be linked to the specified company.
Args:
slug: The Letzshop vendor slug
company_id: The company ID to create the vendor under
"""
sync_service = get_vendor_sync_service(db)
try:
vendor_info = sync_service.create_vendor_from_cache(slug, company_id)
logger.info(
f"Admin {current_admin.email} created vendor {vendor_info['vendor_code']} "
f"from Letzshop vendor {slug}"
)
return LetzshopCreateVendorFromCacheResponse(
message=f"Vendor '{vendor_info['name']}' created successfully",
vendor=vendor_info,
letzshop_vendor_slug=slug,
)
except ValueError as e:
raise ValidationException(str(e))