# 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, 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 ResourceNotFoundException, ValidationException from app.services.letzshop import ( CredentialsNotFoundError, LetzshopClientError, LetzshopCredentialsService, LetzshopOrderService, OrderNotFoundError, VendorNotFoundError, ) from models.database.user import User from models.schema.letzshop import ( FulfillmentOperationResponse, LetzshopConnectionTestRequest, LetzshopConnectionTestResponse, LetzshopCredentialsCreate, LetzshopCredentialsResponse, LetzshopCredentialsUpdate, LetzshopJobItem, LetzshopJobsListResponse, LetzshopOrderListResponse, LetzshopOrderResponse, LetzshopSuccessResponse, LetzshopSyncTriggerRequest, LetzshopSyncTriggerResponse, 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( "/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), sync_status: str | None = Query(None, description="Filter by sync status"), 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, sync_status=sync_status, ) # 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, letzshop_order_id=order.letzshop_order_id, letzshop_shipment_id=order.letzshop_shipment_id, letzshop_order_number=order.letzshop_order_number, letzshop_state=order.letzshop_state, customer_email=order.customer_email, customer_name=order.customer_name, total_amount=order.total_amount, currency=order.currency, local_order_id=order.local_order_id, sync_status=order.sync_status, last_synced_at=order.last_synced_at, sync_error=order.sync_error, confirmed_at=order.confirmed_at, rejected_at=order.rejected_at, tracking_set_at=order.tracking_set_at, tracking_number=order.tracking_number, tracking_carrier=order.tracking_carrier, inventory_units=order.inventory_units, created_at=order.created_at, updated_at=order.updated_at, ) for order in orders ], total=total, skip=skip, limit=limit, stats=stats, ) @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( "/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", ) def import_historical_orders( vendor_id: int = Path(..., description="Vendor ID"), state: str = Query("confirmed", description="Shipment state to import"), max_pages: int | None = Query(None, ge=1, le=100, description="Max pages to fetch"), match_products: bool = Query(True, description="Match EANs to local products"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Import historical orders from Letzshop. Fetches all shipments with the specified state (default: confirmed) and imports them into the database. Supports pagination and EAN matching. Returns statistics on imported/updated/skipped orders and product matching. """ 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}" ) # Fetch all shipments with pagination try: with creds_service.create_client(vendor_id) as client: logger.info( f"Starting historical import for vendor {vendor_id}, state={state}, max_pages={max_pages}" ) shipments = client.get_all_shipments_paginated( state=state, page_size=50, max_pages=max_pages, ) logger.info(f"Fetched {len(shipments)} {state} shipments from Letzshop") # Import shipments stats = order_service.import_historical_shipments( vendor_id=vendor_id, shipments=shipments, match_products=match_products, ) db.commit() # Update sync status creds_service.update_sync_status( vendor_id, "success", None, ) logger.info( f"Historical import completed: {stats['imported']} imported, " f"{stats['updated']} updated, {stats['skipped']} skipped" ) return { "success": True, "message": f"Historical import completed: {stats['imported']} imported, {stats['updated']} updated", "statistics": stats, } except LetzshopClientError as e: creds_service.update_sync_status(vendor_id, "failed", str(e)) raise ValidationException(f"Letzshop API error: {e}") @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. """ 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("LetzshopOrder", str(order_id)) # Get inventory unit IDs from order if not order.inventory_units: return FulfillmentOperationResponse( success=False, message="No inventory units found in order", ) inventory_unit_ids = [u.get("id") for u in order.inventory_units if u.get("id")] if not inventory_unit_ids: return FulfillmentOperationResponse( success=False, message="No inventory unit IDs found in order", ) 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 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("LetzshopOrder", str(order_id)) # Get inventory unit IDs from order if not order.inventory_units: return FulfillmentOperationResponse( success=False, message="No inventory units found in order", ) inventory_unit_ids = [u.get("id") for u in order.inventory_units if u.get("id")] if not inventory_unit_ids: return FulfillmentOperationResponse( success=False, message="No inventory unit IDs found in order", ) 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 order status 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="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. """ 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("LetzshopOrder", str(order_id)) 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 inventory unit 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="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("LetzshopOrder", 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 inventory unit 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))