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

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