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:
@@ -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