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

View File

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