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))
|
||||
|
||||
@@ -13,11 +13,15 @@ import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.exceptions import ResourceNotFoundException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
|
||||
from app.services.platform_signup_service import platform_signup_service
|
||||
from models.database.letzshop import LetzshopVendorCache
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,13 +38,40 @@ class LetzshopVendorInfo(BaseModel):
|
||||
letzshop_id: str | None = None
|
||||
slug: str
|
||||
name: str
|
||||
company_name: str | None = None
|
||||
description: str | None = None
|
||||
logo_url: str | None = None
|
||||
category: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
website: str | None = None
|
||||
address: str | None = None
|
||||
city: str | None = None
|
||||
categories: list[str] = []
|
||||
background_image_url: str | None = None
|
||||
social_media_links: list[str] = []
|
||||
letzshop_url: str
|
||||
is_claimed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_cache(cls, cache: LetzshopVendorCache, lang: str = "en") -> "LetzshopVendorInfo":
|
||||
"""Create from cache entry."""
|
||||
return cls(
|
||||
letzshop_id=cache.letzshop_id,
|
||||
slug=cache.slug,
|
||||
name=cache.name,
|
||||
company_name=cache.company_name,
|
||||
description=cache.get_description(lang),
|
||||
email=cache.email,
|
||||
phone=cache.phone,
|
||||
website=cache.website,
|
||||
address=cache.get_full_address(),
|
||||
city=cache.city,
|
||||
categories=cache.categories or [],
|
||||
background_image_url=cache.background_image_url,
|
||||
social_media_links=cache.social_media_links or [],
|
||||
letzshop_url=cache.letzshop_url,
|
||||
is_claimed=cache.is_claimed,
|
||||
)
|
||||
|
||||
|
||||
class LetzshopVendorListResponse(BaseModel):
|
||||
"""Paginated list of Letzshop vendors."""
|
||||
@@ -113,35 +144,42 @@ async def list_letzshop_vendors(
|
||||
search: Annotated[str | None, Query(description="Search by name")] = None,
|
||||
category: Annotated[str | None, Query(description="Filter by category")] = None,
|
||||
city: Annotated[str | None, Query(description="Filter by city")] = None,
|
||||
only_unclaimed: Annotated[bool, Query(description="Only show unclaimed vendors")] = False,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 20,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorListResponse:
|
||||
"""
|
||||
List Letzshop vendors (placeholder - will fetch from cache/API).
|
||||
List Letzshop vendors from cached directory.
|
||||
|
||||
In production, this would fetch from a cached vendor list
|
||||
that is periodically synced from Letzshop's public directory.
|
||||
The cache is periodically synced from Letzshop's public GraphQL API.
|
||||
Run the sync task manually or wait for scheduled sync if cache is empty.
|
||||
"""
|
||||
# TODO: Implement actual Letzshop vendor listing
|
||||
# For now, return placeholder data to allow UI development
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# This is placeholder data - in production, we would:
|
||||
# 1. Query our cached letzshop_vendor_cache table
|
||||
# 2. Or fetch from Letzshop's public API if available
|
||||
|
||||
# Return empty list for now - the actual data will come from Phase 4
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[],
|
||||
total=0,
|
||||
vendors, total = sync_service.search_cached_vendors(
|
||||
search=search,
|
||||
city=city,
|
||||
category=category,
|
||||
only_unclaimed=only_unclaimed,
|
||||
page=page,
|
||||
limit=limit,
|
||||
has_more=False,
|
||||
)
|
||||
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[LetzshopVendorInfo.from_cache(v, lang) for v in vendors],
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit,
|
||||
has_more=(page * limit) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/letzshop-vendors/lookup", response_model=LetzshopLookupResponse) # public
|
||||
async def lookup_letzshop_vendor(
|
||||
request: LetzshopLookupRequest,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopLookupResponse:
|
||||
"""
|
||||
@@ -149,7 +187,7 @@ async def lookup_letzshop_vendor(
|
||||
|
||||
This endpoint:
|
||||
1. Extracts the slug from the provided URL
|
||||
2. Attempts to fetch vendor info from Letzshop
|
||||
2. Looks up vendor in local cache (or fetches from Letzshop if not cached)
|
||||
3. Checks if the vendor is already claimed on our platform
|
||||
4. Returns vendor info for signup pre-fill
|
||||
"""
|
||||
@@ -162,23 +200,25 @@ async def lookup_letzshop_vendor(
|
||||
error="Could not extract vendor slug from URL",
|
||||
)
|
||||
|
||||
# Check if already claimed (using service layer)
|
||||
is_claimed = platform_signup_service.check_vendor_claimed(db, slug)
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# TODO: Fetch actual vendor info from Letzshop (Phase 4)
|
||||
# For now, return basic info based on the slug
|
||||
letzshop_url = f"https://letzshop.lu/vendors/{slug}"
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
vendor_info = LetzshopVendorInfo(
|
||||
slug=slug,
|
||||
name=slug.replace("-", " ").title(), # Placeholder name
|
||||
letzshop_url=letzshop_url,
|
||||
is_claimed=is_claimed,
|
||||
)
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
if not cache_entry:
|
||||
return LetzshopLookupResponse(
|
||||
found=False,
|
||||
error="Vendor not found on Letzshop",
|
||||
)
|
||||
|
||||
return LetzshopLookupResponse(
|
||||
found=True,
|
||||
vendor=vendor_info,
|
||||
vendor=LetzshopVendorInfo.from_cache(cache_entry, lang),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -192,26 +232,40 @@ async def lookup_letzshop_vendor(
|
||||
@router.get("/letzshop-vendors/{slug}", response_model=LetzshopVendorInfo) # public
|
||||
async def get_letzshop_vendor(
|
||||
slug: str,
|
||||
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
|
||||
db: Session = Depends(get_db),
|
||||
) -> LetzshopVendorInfo:
|
||||
"""
|
||||
Get a specific Letzshop vendor by slug.
|
||||
|
||||
Returns 404 if vendor not found.
|
||||
Returns 404 if vendor not found in cache or on Letzshop.
|
||||
"""
|
||||
slug = slug.lower()
|
||||
|
||||
# Check if claimed (using service layer)
|
||||
is_claimed = platform_signup_service.check_vendor_claimed(db, slug)
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
|
||||
# TODO: Fetch actual vendor info from cache/API (Phase 4)
|
||||
# For now, return placeholder based on slug
|
||||
# First try cache
|
||||
cache_entry = sync_service.get_cached_vendor(slug)
|
||||
|
||||
letzshop_url = f"https://letzshop.lu/vendors/{slug}"
|
||||
# If not in cache, try to fetch from Letzshop
|
||||
if not cache_entry:
|
||||
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
|
||||
cache_entry = sync_service.sync_single_vendor(slug)
|
||||
|
||||
return LetzshopVendorInfo(
|
||||
slug=slug,
|
||||
name=slug.replace("-", " ").title(),
|
||||
letzshop_url=letzshop_url,
|
||||
is_claimed=is_claimed,
|
||||
)
|
||||
if not cache_entry:
|
||||
raise ResourceNotFoundException("LetzshopVendor", slug)
|
||||
|
||||
return LetzshopVendorInfo.from_cache(cache_entry, lang)
|
||||
|
||||
|
||||
@router.get("/letzshop-vendors-stats") # public
|
||||
async def get_letzshop_vendor_stats(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Get statistics about the Letzshop vendor cache.
|
||||
|
||||
Returns total, active, claimed, and unclaimed vendor counts.
|
||||
"""
|
||||
sync_service = LetzshopVendorSyncService(db)
|
||||
return sync_service.get_sync_stats()
|
||||
|
||||
Reference in New Issue
Block a user