feat: add Letzshop bidirectional order integration
Add complete Letzshop marketplace integration with: - GraphQL client for order import and fulfillment operations - Encrypted credential storage per vendor (Fernet encryption) - Admin and vendor API endpoints for credentials management - Order import, confirmation, rejection, and tracking - Fulfillment queue and sync logging - Comprehensive documentation and test coverage New files: - app/services/letzshop/ - GraphQL client and services - app/utils/encryption.py - Fernet encryption utility - models/database/letzshop.py - Database models - models/schema/letzshop.py - Pydantic schemas - app/api/v1/admin/letzshop.py - Admin API endpoints - app/api/v1/vendor/letzshop.py - Vendor API endpoints - docs/guides/letzshop-order-integration.md - Documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ from . import (
|
||||
companies,
|
||||
content_pages,
|
||||
dashboard,
|
||||
letzshop,
|
||||
logs,
|
||||
marketplace,
|
||||
monitoring,
|
||||
@@ -114,6 +115,9 @@ router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
||||
# Include marketplace monitoring endpoints
|
||||
router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||
|
||||
# Include Letzshop integration endpoints
|
||||
router.include_router(letzshop.router, tags=["admin-letzshop"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Administration
|
||||
|
||||
470
app/api/v1/admin/letzshop.py
Normal file
470
app/api/v1/admin/letzshop.py
Normal file
@@ -0,0 +1,470 @@
|
||||
# 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,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.letzshop import (
|
||||
LetzshopConnectionTestRequest,
|
||||
LetzshopConnectionTestResponse,
|
||||
LetzshopCredentialsCreate,
|
||||
LetzshopCredentialsResponse,
|
||||
LetzshopCredentialsUpdate,
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
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)],
|
||||
)
|
||||
Reference in New Issue
Block a user