Files
orion/app/api/v1/admin/letzshop.py
Samir Boulahtit ccfbbcb804 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>
2026-01-13 20:35:46 +01:00

1520 lines
51 KiB
Python

# app/api/v1/admin/letzshop.py
"""
Admin API endpoints for Letzshop marketplace integration.
Provides admin-level management of:
- Per-vendor Letzshop credentials
- Connection testing
- Sync triggers and status
- Order overview
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
LetzshopVendorSyncService,
OrderNotFoundError,
VendorNotFoundError,
)
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,
LetzshopHistoricalImportJobResponse,
LetzshopHistoricalImportStartResponse,
LetzshopJobItem,
LetzshopJobsListResponse,
LetzshopOrderDetailResponse,
LetzshopOrderItemResponse,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopOrderStats,
LetzshopSuccessResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
LetzshopVendorDirectoryStats,
LetzshopVendorDirectoryStatsResponse,
LetzshopVendorDirectorySyncResponse,
LetzshopVendorListResponse,
LetzshopVendorOverview,
)
router = APIRouter(prefix="/letzshop")
logger = logging.getLogger(__name__)
# ============================================================================
# Helper Functions
# ============================================================================
def get_order_service(db: Session) -> LetzshopOrderService:
"""Get order service instance."""
return LetzshopOrderService(db)
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
"""Get credentials service instance."""
return LetzshopCredentialsService(db)
# ============================================================================
# Vendor Overview
# ============================================================================
@router.get("/vendors", response_model=LetzshopVendorListResponse)
def list_vendors_letzshop_status(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
configured_only: bool = Query(
False, description="Only show vendors with Letzshop configured"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List all vendors with their Letzshop integration status.
Shows which vendors have Letzshop configured, sync status, and pending orders.
"""
order_service = get_order_service(db)
vendor_overviews, total = order_service.list_vendors_with_letzshop_status(
skip=skip,
limit=limit,
configured_only=configured_only,
)
return LetzshopVendorListResponse(
vendors=[LetzshopVendorOverview(**v) for v in vendor_overviews],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Credentials Management
# ============================================================================
@router.get(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def get_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get Letzshop credentials for a vendor (API key is masked)."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
try:
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.post(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def create_or_update_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
credentials_data: LetzshopCredentialsCreate = ...,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Create or update Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
credentials = creds_service.upsert_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
db.commit()
logger.info(
f"Admin {current_admin.email} updated Letzshop credentials for vendor {vendor.name}"
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.patch(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopCredentialsResponse,
)
def update_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
credentials_data: LetzshopCredentialsUpdate = ...,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Partially update Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
try:
credentials = creds_service.update_credentials(
vendor_id=vendor_id,
api_key=credentials_data.api_key,
api_endpoint=credentials_data.api_endpoint,
auto_sync_enabled=credentials_data.auto_sync_enabled,
sync_interval_minutes=credentials_data.sync_interval_minutes,
)
db.commit()
except CredentialsNotFoundError:
raise ResourceNotFoundException(
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
return LetzshopCredentialsResponse(
id=credentials.id,
vendor_id=credentials.vendor_id,
api_key_masked=creds_service.get_masked_api_key(vendor_id),
api_endpoint=credentials.api_endpoint,
auto_sync_enabled=credentials.auto_sync_enabled,
sync_interval_minutes=credentials.sync_interval_minutes,
last_sync_at=credentials.last_sync_at,
last_sync_status=credentials.last_sync_status,
last_sync_error=credentials.last_sync_error,
created_at=credentials.created_at,
updated_at=credentials.updated_at,
)
@router.delete(
"/vendors/{vendor_id}/credentials",
response_model=LetzshopSuccessResponse,
)
def delete_vendor_credentials(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Delete Letzshop credentials for a vendor."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
deleted = creds_service.delete_credentials(vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials",
str(vendor_id),
message=f"Letzshop credentials not configured for vendor {vendor.name}",
)
db.commit()
logger.info(
f"Admin {current_admin.email} deleted Letzshop credentials for vendor {vendor.name}"
)
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@router.post(
"/vendors/{vendor_id}/test",
response_model=LetzshopConnectionTestResponse,
)
def test_vendor_connection(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Test the Letzshop connection for a vendor using stored credentials."""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
success, response_time_ms, error = creds_service.test_connection(vendor_id)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
@router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Test a Letzshop API key without saving it."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_api_key(
api_key=test_request.api_key,
api_endpoint=test_request.api_endpoint,
)
return LetzshopConnectionTestResponse(
success=success,
message="Connection successful" if success else "Connection failed",
response_time_ms=response_time_ms,
error_details=error,
)
# ============================================================================
# Order Management
# ============================================================================
@router.get(
"/orders",
response_model=LetzshopOrderListResponse,
)
def list_all_letzshop_orders(
vendor_id: int | None = Query(None, description="Filter by vendor"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
has_declined_items: bool | None = Query(
None, description="Filter orders with declined/unavailable items"
),
search: str | None = Query(
None, description="Search by order number, customer name, or email"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
List Letzshop orders across all vendors (or for a specific vendor).
When vendor_id is not provided, returns orders from all vendors.
"""
order_service = get_order_service(db)
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
has_declined_items=has_declined_items,
search=search,
)
# Get order stats (cross-vendor or vendor-specific)
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
vendor_name=order.vendor.name if order.vendor else None,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
stats=LetzshopOrderStats(**stats),
)
@router.get(
"/vendors/{vendor_id}/orders",
response_model=LetzshopOrderListResponse,
)
def list_vendor_letzshop_orders(
vendor_id: int = Path(..., description="Vendor ID"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
has_declined_items: bool | None = Query(
None, description="Filter orders with declined/unavailable items"
),
search: str | None = Query(
None, description="Search by order number, customer name, or email"
),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""List Letzshop orders for a vendor."""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
has_declined_items=has_declined_items,
search=search,
)
# Get order stats for all statuses
stats = order_service.get_order_stats(vendor_id)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
stats=LetzshopOrderStats(**stats),
)
@router.get(
"/orders/{order_id}",
response_model=LetzshopOrderDetailResponse,
)
def get_letzshop_order_detail(
order_id: int = Path(..., description="Letzshop order ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get detailed information for a single Letzshop order."""
order_service = get_order_service(db)
order = order_service.get_order_by_id(order_id)
if not order:
raise ResourceNotFoundException("Order", str(order_id))
return LetzshopOrderDetailResponse(
id=order.id,
vendor_id=order.vendor_id,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=order.customer_full_name,
customer_locale=order.customer_locale,
customer_first_name=order.customer_first_name,
customer_last_name=order.customer_last_name,
customer_phone=order.customer_phone,
ship_country_iso=order.ship_country_iso,
ship_first_name=order.ship_first_name,
ship_last_name=order.ship_last_name,
ship_company=order.ship_company,
ship_address_line_1=order.ship_address_line_1,
ship_address_line_2=order.ship_address_line_2,
ship_city=order.ship_city,
ship_postal_code=order.ship_postal_code,
bill_country_iso=order.bill_country_iso,
bill_first_name=order.bill_first_name,
bill_last_name=order.bill_last_name,
bill_company=order.bill_company,
bill_address_line_1=order.bill_address_line_1,
bill_address_line_2=order.bill_address_line_2,
bill_city=order.bill_city,
bill_postal_code=order.bill_postal_code,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
external_data=order.external_data,
customer_notes=order.customer_notes,
internal_notes=order.internal_notes,
items=[
LetzshopOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
gtin=item.gtin,
gtin_type=item.gtin_type,
quantity=item.quantity,
unit_price=item.unit_price,
total_price=item.total_price,
external_item_id=item.external_item_id,
external_variant_id=item.external_variant_id,
item_state=item.item_state,
)
for item in order.items
],
)
@router.post(
"/vendors/{vendor_id}/sync",
response_model=LetzshopSyncTriggerResponse,
)
def trigger_vendor_sync(
vendor_id: int = Path(..., description="Vendor ID"),
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Trigger a sync operation for a vendor.
This imports new orders from Letzshop.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor.name}"
)
# Import orders using the client
try:
with creds_service.create_client(vendor_id) as client:
shipments = client.get_unconfirmed_shipments()
logger.info(
f"Letzshop sync for vendor {vendor_id}: "
f"fetched {len(shipments)} unconfirmed shipments from API"
)
orders_imported = 0
orders_updated = 0
errors = []
for shipment in shipments:
try:
# Check if order already exists
existing = order_service.get_order_by_shipment_id(
vendor_id, shipment["id"]
)
if existing:
# Update existing order
order_service.update_order_from_shipment(existing, shipment)
orders_updated += 1
else:
# Create new order
order_service.create_order(vendor_id, shipment)
orders_imported += 1
except Exception as e:
errors.append(
f"Error processing shipment {shipment.get('id')}: {e}"
)
db.commit()
# Update sync status
creds_service.update_sync_status(
vendor_id,
"success" if not errors else "partial",
"; ".join(errors) if errors else None,
)
return LetzshopSyncTriggerResponse(
success=True,
message=f"Sync completed: {orders_imported} imported, {orders_updated} updated",
orders_imported=orders_imported,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
creds_service.update_sync_status(vendor_id, "failed", str(e))
return LetzshopSyncTriggerResponse(
success=False,
message=f"Sync failed: {e}",
errors=[str(e)],
)
# ============================================================================
# Jobs (Unified view of imports, exports, and syncs)
# ============================================================================
@router.get(
"/jobs",
response_model=LetzshopJobsListResponse,
)
def list_all_letzshop_jobs(
job_type: str | None = Query(None, description="Filter: import, export, order_sync, historical_import"),
status: str | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get unified list of all Letzshop-related jobs across all vendors.
Combines product imports, exports, and order syncs.
"""
order_service = get_order_service(db)
jobs_data, total = order_service.list_letzshop_jobs(
vendor_id=None,
job_type=job_type,
status=status,
skip=skip,
limit=limit,
)
jobs = [LetzshopJobItem(**job) for job in jobs_data]
return LetzshopJobsListResponse(jobs=jobs, total=total)
@router.get(
"/vendors/{vendor_id}/jobs",
response_model=LetzshopJobsListResponse,
)
def list_vendor_letzshop_jobs(
vendor_id: int = Path(..., description="Vendor ID"),
job_type: str | None = Query(None, description="Filter: import, export, order_sync"),
status: str | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get unified list of Letzshop-related jobs for a vendor.
Combines product imports, exports, and order syncs.
"""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Use service layer for database queries
jobs_data, total = order_service.list_letzshop_jobs(
vendor_id=vendor_id,
job_type=job_type,
status=status,
skip=skip,
limit=limit,
)
# Convert dict data to Pydantic models
jobs = [LetzshopJobItem(**job) for job in jobs_data]
return LetzshopJobsListResponse(jobs=jobs, total=total)
# ============================================================================
# Historical Import
# ============================================================================
@router.post(
"/vendors/{vendor_id}/import-history",
response_model=LetzshopHistoricalImportStartResponse,
)
def start_historical_import(
vendor_id: int = Path(..., description="Vendor ID"),
background_tasks: BackgroundTasks = None,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Start historical order import from Letzshop as a background job.
Creates a job that imports both confirmed and declined orders with
real-time progress tracking. Poll the status endpoint to track progress.
Returns a job_id for polling the status endpoint.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
vendor = order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor.name}"
)
# Check if there's already a running import for this vendor
existing_job = order_service.get_running_historical_import_job(vendor_id)
if existing_job:
raise ValidationException(
f"Historical import already in progress (job_id={existing_job.id})"
)
# Create job record
job = order_service.create_historical_import_job(vendor_id, current_admin.id)
logger.info(f"Created historical import job {job.id} for vendor {vendor_id}")
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_historical_import(
background_tasks=background_tasks,
job_id=job.id,
vendor_id=vendor_id,
)
# Store Celery task ID if using Celery
if celery_task_id:
job.celery_task_id = celery_task_id
db.commit()
return LetzshopHistoricalImportStartResponse(
job_id=job.id,
status="pending",
message="Historical import job started",
)
@router.get(
"/vendors/{vendor_id}/import-history/{job_id}/status",
response_model=LetzshopHistoricalImportJobResponse,
)
def get_historical_import_status(
vendor_id: int = Path(..., description="Vendor ID"),
job_id: int = Path(..., description="Import job ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get status of a historical import job.
Poll this endpoint to track import progress. Returns current phase,
page being fetched, and counts of processed/imported/updated orders.
"""
order_service = get_order_service(db)
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
if not job:
raise ResourceNotFoundException("HistoricalImportJob", str(job_id))
return LetzshopHistoricalImportJobResponse.model_validate(job)
@router.get(
"/vendors/{vendor_id}/import-summary",
)
def get_import_summary(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get summary statistics for imported Letzshop orders.
Returns total orders, unique customers, and breakdowns by state/locale/country.
"""
order_service = get_order_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
summary = order_service.get_historical_import_summary(vendor_id)
return {
"success": True,
"summary": summary,
}
# ============================================================================
# Fulfillment Operations (Admin)
# ============================================================================
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/confirm",
response_model=FulfillmentOperationResponse,
)
def confirm_order(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Confirm all inventory units for a Letzshop order.
Sends confirmInventoryUnits mutation with isAvailable=true for all items.
Raises:
OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Check for unresolved exceptions (blocks confirmation)
unresolved_count = order_item_exception_service.get_unresolved_exception_count(
db, order_id
)
if unresolved_count > 0:
raise OrderHasUnresolvedExceptionsException(order_id, unresolved_count)
# Get inventory unit IDs from order items
items = order_service.get_order_items(order)
if not items:
return FulfillmentOperationResponse(
success=False,
message="No items found in order",
)
inventory_unit_ids = [
item.external_item_id for item in items if item.external_item_id
]
if not inventory_unit_ids:
return FulfillmentOperationResponse(
success=False,
message="No inventory unit IDs found in order items",
)
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units(inventory_unit_ids)
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be confirmed",
errors=error_messages,
)
# Update order status and item states
for item in items:
if item.external_item_id:
order_service.update_inventory_unit_state(
order, item.external_item_id, "confirmed_available"
)
order_service.mark_order_confirmed(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Confirmed {len(inventory_unit_ids)} inventory units",
confirmed_units=[u.get("id") for u in result.get("inventoryUnits", [])],
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/reject",
response_model=FulfillmentOperationResponse,
)
def reject_order(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Decline all inventory units for a Letzshop order.
Sends confirmInventoryUnits mutation with isAvailable=false for all items.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Get inventory unit IDs from order items
items = order_service.get_order_items(order)
if not items:
return FulfillmentOperationResponse(
success=False,
message="No items found in order",
)
inventory_unit_ids = [
item.external_item_id for item in items if item.external_item_id
]
if not inventory_unit_ids:
return FulfillmentOperationResponse(
success=False,
message="No inventory unit IDs found in order items",
)
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units(inventory_unit_ids)
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be declined",
errors=error_messages,
)
# Update item states and order status
for item in items:
if item.external_item_id:
order_service.update_inventory_unit_state(
order, item.external_item_id, "confirmed_unavailable"
)
order_service.mark_order_rejected(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Declined {len(inventory_unit_ids)} inventory units",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/confirm",
response_model=FulfillmentOperationResponse,
)
def confirm_single_item(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
item_id: str = Path(..., description="External Item ID (Letzshop inventory unit ID)"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Confirm a single inventory unit in an order.
Sends confirmInventoryUnits mutation with isAvailable=true for one item.
Raises:
OrderHasUnresolvedExceptionsException: If the specific item has an unresolved exception
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
# Check if this specific item has an unresolved exception
# Find the order item by external_item_id
item = next(
(i for i in order.items if i.external_item_id == item_id),
None
)
if item and item.needs_product_match:
raise OrderHasUnresolvedExceptionsException(order_id, 1)
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units([item_id])
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Failed to confirm item",
errors=error_messages,
)
# Update local order item state
order_service.update_inventory_unit_state(order, item_id, "confirmed_available")
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Item confirmed",
confirmed_units=[item_id],
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post(
"/vendors/{vendor_id}/orders/{order_id}/items/{item_id}/decline",
response_model=FulfillmentOperationResponse,
)
def decline_single_item(
vendor_id: int = Path(..., description="Vendor ID"),
order_id: int = Path(..., description="Order ID"),
item_id: str = Path(..., description="External Item ID (Letzshop inventory unit ID)"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Decline a single inventory unit in an order.
Sends confirmInventoryUnits mutation with isAvailable=false for one item.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order = order_service.get_order_or_raise(vendor_id, order_id)
except OrderNotFoundError:
raise ResourceNotFoundException("Order", str(order_id))
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units([item_id])
if not result.get("inventoryUnits"):
error_messages = [
e.get("message", "Unknown error")
for e in result.get("errors", [])
]
return FulfillmentOperationResponse(
success=False,
message="Failed to decline item",
errors=error_messages,
)
# Update local order item state
order_service.update_inventory_unit_state(order, item_id, "confirmed_unavailable")
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Item declined",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Tracking Sync
# ============================================================================
@router.post(
"/vendors/{vendor_id}/sync-tracking",
response_model=LetzshopSyncTriggerResponse,
)
def sync_tracking_for_vendor(
vendor_id: int = Path(..., description="Vendor ID"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Sync tracking information from Letzshop for confirmed orders.
Fetches tracking data from Letzshop API for orders that:
- Are in "processing" status (confirmed)
- Don't have tracking info yet
- Have an external shipment ID
This is useful when tracking is added by Letzshop after order confirmation.
"""
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
try:
order_service.get_vendor_or_raise(vendor_id)
except VendorNotFoundError:
raise ResourceNotFoundException("Vendor", str(vendor_id))
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException(
f"Letzshop credentials not configured for vendor {vendor_id}"
)
# Get orders that need tracking
orders_without_tracking = order_service.get_orders_without_tracking(vendor_id)
if not orders_without_tracking:
return LetzshopSyncTriggerResponse(
success=True,
message="No orders need tracking updates",
orders_imported=0,
orders_updated=0,
)
logger.info(
f"Syncing tracking for {len(orders_without_tracking)} orders (vendor {vendor_id})"
)
orders_updated = 0
errors = []
try:
with creds_service.create_client(vendor_id) as client:
for order in orders_without_tracking:
try:
# Fetch shipment by ID
shipment_data = client.get_shipment_by_id(order.external_shipment_id)
if shipment_data:
updated = order_service.update_tracking_from_shipment_data(
order, shipment_data
)
if updated:
orders_updated += 1
except Exception as e:
errors.append(
f"Error syncing tracking for order {order.order_number}: {e}"
)
db.commit()
message = f"Tracking sync completed: {orders_updated} orders updated"
if errors:
message += f" ({len(errors)} errors)"
return LetzshopSyncTriggerResponse(
success=True,
message=message,
orders_imported=0,
orders_updated=orders_updated,
errors=errors,
)
except LetzshopClientError as e:
return LetzshopSyncTriggerResponse(
success=False,
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))