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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user