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:
2025-12-13 12:19:54 +01:00
parent 837b1f93f4
commit 448f01f82b
20 changed files with 5251 additions and 0 deletions

View File

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

View 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)],
)

View File

@@ -21,6 +21,7 @@ from . import (
dashboard,
info,
inventory,
letzshop,
marketplace,
media,
notifications,
@@ -59,6 +60,7 @@ router.include_router(customers.router, tags=["vendor-customers"])
router.include_router(team.router, tags=["vendor-team"])
router.include_router(inventory.router, tags=["vendor-inventory"])
router.include_router(marketplace.router, tags=["vendor-marketplace"])
router.include_router(letzshop.router, tags=["vendor-letzshop"])
# Services (with prefixes: /payments/*, /media/*, etc.)
router.include_router(payments.router, tags=["vendor-payments"])

689
app/api/v1/vendor/letzshop.py vendored Normal file
View File

@@ -0,0 +1,689 @@
# app/api/v1/vendor/letzshop.py
"""
Vendor API endpoints for Letzshop marketplace integration.
Provides vendor-level management of:
- Letzshop credentials
- Connection testing
- Order import and sync
- Fulfillment operations (confirm, reject, tracking)
Vendor Context: Uses token_vendor_id from JWT token.
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException, ValidationException
from app.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
OrderNotFoundError,
)
from models.database.user import User
from models.schema.letzshop import (
FulfillmentConfirmRequest,
FulfillmentOperationResponse,
FulfillmentQueueItemResponse,
FulfillmentQueueListResponse,
FulfillmentRejectRequest,
FulfillmentTrackingRequest,
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsStatus,
LetzshopCredentialsUpdate,
LetzshopOrderDetailResponse,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopSuccessResponse,
LetzshopSyncLogListResponse,
LetzshopSyncLogResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
)
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)
# ============================================================================
# Status & Configuration
# ============================================================================
@router.get("/status", response_model=LetzshopCredentialsStatus)
def get_letzshop_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get Letzshop integration status for the current vendor."""
creds_service = get_credentials_service(db)
status = creds_service.get_status(current_user.token_vendor_id)
return LetzshopCredentialsStatus(**status)
@router.get("/credentials", response_model=LetzshopCredentialsResponse)
def get_credentials(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get Letzshop credentials for the current vendor (API key is masked)."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_vendor_id
try:
credentials = creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id))
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("/credentials", response_model=LetzshopCredentialsResponse)
def save_credentials(
credentials_data: LetzshopCredentialsCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Create or update Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_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"Vendor user {current_user.email} updated Letzshop credentials")
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("/credentials", response_model=LetzshopCredentialsResponse)
def update_credentials(
credentials_data: LetzshopCredentialsUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Partially update Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
vendor_id = current_user.token_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))
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("/credentials", response_model=LetzshopSuccessResponse)
def delete_credentials(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Delete Letzshop credentials for the current vendor."""
creds_service = get_credentials_service(db)
deleted = creds_service.delete_credentials(current_user.token_vendor_id)
if not deleted:
raise ResourceNotFoundException(
"LetzshopCredentials", str(current_user.token_vendor_id)
)
logger.info(f"Vendor user {current_user.email} deleted Letzshop credentials")
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_connection(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Test the Letzshop connection using stored credentials."""
creds_service = get_credentials_service(db)
success, response_time_ms, error = creds_service.test_connection(
current_user.token_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-key", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""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_orders(
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"),
letzshop_state: str | None = Query(None, description="Filter by Letzshop state"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List Letzshop orders for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
orders, total = order_service.list_orders(
vendor_id=vendor_id,
skip=skip,
limit=limit,
sync_status=sync_status,
letzshop_state=letzshop_state,
)
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.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse)
def get_order(
order_id: int = Path(..., description="Order ID"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get a specific Letzshop order with full details."""
order_service = get_order_service(db)
try:
order = order_service.get_order_or_raise(
current_user.token_vendor_id, order_id
)
except OrderNotFoundError:
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
return LetzshopOrderDetailResponse(
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,
raw_order_data=order.raw_order_data,
created_at=order.created_at,
updated_at=order.updated_at,
)
@router.post("/orders/import", response_model=LetzshopSyncTriggerResponse)
def import_orders(
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import new orders from Letzshop."""
vendor_id = current_user.token_vendor_id
order_service = get_order_service(db)
creds_service = get_credentials_service(db)
# Verify credentials exist
try:
creds_service.get_credentials_or_raise(vendor_id)
except CredentialsNotFoundError:
raise ValidationException("Letzshop credentials not configured")
# Import orders
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:
existing = order_service.get_order_by_shipment_id(
vendor_id, shipment["id"]
)
if existing:
order_service.update_order_from_shipment(existing, shipment)
orders_updated += 1
else:
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()
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"Import 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"Import failed: {e}",
errors=[str(e)],
)
# ============================================================================
# Fulfillment Operations
# ============================================================================
@router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse)
def confirm_order(
order_id: int = Path(..., description="Order ID"),
confirm_request: FulfillmentConfirmRequest | None = None,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Confirm inventory units for a Letzshop order."""
vendor_id = current_user.token_vendor_id
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 request or order
if confirm_request and confirm_request.inventory_unit_ids:
inventory_unit_ids = confirm_request.inventory_unit_ids
elif order.inventory_units:
inventory_unit_ids = [u["id"] for u in order.inventory_units]
else:
raise ValidationException("No inventory units to confirm")
try:
with creds_service.create_client(vendor_id) as client:
result = client.confirm_inventory_units(inventory_unit_ids)
# Check for errors
if result.get("errors"):
error_messages = [
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["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("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse)
def reject_order(
order_id: int = Path(..., description="Order ID"),
reject_request: FulfillmentRejectRequest | None = None,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Reject inventory units for a Letzshop order."""
vendor_id = current_user.token_vendor_id
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 request or order
if reject_request and reject_request.inventory_unit_ids:
inventory_unit_ids = reject_request.inventory_unit_ids
elif order.inventory_units:
inventory_unit_ids = [u["id"] for u in order.inventory_units]
else:
raise ValidationException("No inventory units to reject")
try:
with creds_service.create_client(vendor_id) as client:
result = client.reject_inventory_units(inventory_unit_ids)
if result.get("errors"):
error_messages = [
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Some inventory units could not be rejected",
errors=error_messages,
)
order_service.mark_order_rejected(order)
db.commit()
return FulfillmentOperationResponse(
success=True,
message=f"Rejected {len(inventory_unit_ids)} inventory units",
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
@router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse)
def set_order_tracking(
order_id: int = Path(..., description="Order ID"),
tracking_request: FulfillmentTrackingRequest = ...,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Set tracking information for a Letzshop order."""
vendor_id = current_user.token_vendor_id
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))
if not order.letzshop_shipment_id:
raise ValidationException("Order does not have a shipment ID")
try:
with creds_service.create_client(vendor_id) as client:
result = client.set_shipment_tracking(
shipment_id=order.letzshop_shipment_id,
tracking_code=tracking_request.tracking_number,
tracking_provider=tracking_request.tracking_carrier,
)
if result.get("errors"):
error_messages = [
f"{e.get('code', 'unknown')}: {e.get('message', 'Unknown error')}"
for e in result["errors"]
]
return FulfillmentOperationResponse(
success=False,
message="Failed to set tracking",
errors=error_messages,
)
order_service.set_order_tracking(
order,
tracking_request.tracking_number,
tracking_request.tracking_carrier,
)
db.commit()
return FulfillmentOperationResponse(
success=True,
message="Tracking information set",
tracking_number=tracking_request.tracking_number,
tracking_carrier=tracking_request.tracking_carrier,
)
except LetzshopClientError as e:
return FulfillmentOperationResponse(success=False, message=str(e))
# ============================================================================
# Sync Logs
# ============================================================================
@router.get("/logs", response_model=LetzshopSyncLogListResponse)
def list_sync_logs(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List Letzshop sync logs for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
logs, total = order_service.list_sync_logs(
vendor_id=vendor_id,
skip=skip,
limit=limit,
)
return LetzshopSyncLogListResponse(
logs=[
LetzshopSyncLogResponse(
id=log.id,
vendor_id=log.vendor_id,
operation_type=log.operation_type,
direction=log.direction,
status=log.status,
records_processed=log.records_processed,
records_succeeded=log.records_succeeded,
records_failed=log.records_failed,
error_details=log.error_details,
started_at=log.started_at,
completed_at=log.completed_at,
duration_seconds=log.duration_seconds,
triggered_by=log.triggered_by,
created_at=log.created_at,
)
for log in logs
],
total=total,
skip=skip,
limit=limit,
)
# ============================================================================
# Fulfillment Queue
# ============================================================================
@router.get("/queue", response_model=FulfillmentQueueListResponse)
def list_fulfillment_queue(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by status"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""List fulfillment queue items for the current vendor."""
order_service = get_order_service(db)
vendor_id = current_user.token_vendor_id
items, total = order_service.list_fulfillment_queue(
vendor_id=vendor_id,
skip=skip,
limit=limit,
status=status,
)
return FulfillmentQueueListResponse(
items=[
FulfillmentQueueItemResponse(
id=item.id,
vendor_id=item.vendor_id,
letzshop_order_id=item.letzshop_order_id,
operation=item.operation,
payload=item.payload,
status=item.status,
attempts=item.attempts,
max_attempts=item.max_attempts,
last_attempt_at=item.last_attempt_at,
next_retry_at=item.next_retry_at,
error_message=item.error_message,
completed_at=item.completed_at,
response_data=item.response_data,
created_at=item.created_at,
updated_at=item.updated_at,
)
for item in items
],
total=total,
skip=skip,
limit=limit,
)

View File

@@ -0,0 +1,45 @@
# app/services/letzshop/__init__.py
"""
Letzshop marketplace integration services.
Provides:
- GraphQL client for API communication
- Credential management service
- Order import service
- Fulfillment sync service
"""
from .client import (
LetzshopAPIError,
LetzshopAuthError,
LetzshopClient,
LetzshopClientError,
LetzshopConnectionError,
)
from .credentials import (
CredentialsError,
CredentialsNotFoundError,
LetzshopCredentialsService,
)
from .order_service import (
LetzshopOrderService,
OrderNotFoundError,
VendorNotFoundError,
)
__all__ = [
# Client
"LetzshopClient",
"LetzshopClientError",
"LetzshopAuthError",
"LetzshopAPIError",
"LetzshopConnectionError",
# Credentials
"LetzshopCredentialsService",
"CredentialsError",
"CredentialsNotFoundError",
# Order Service
"LetzshopOrderService",
"OrderNotFoundError",
"VendorNotFoundError",
]

View File

@@ -0,0 +1,493 @@
# app/services/letzshop/client.py
"""
GraphQL client for Letzshop marketplace API.
Handles authentication, request formatting, and error handling
for all Letzshop API operations.
"""
import logging
import time
from typing import Any
import requests
logger = logging.getLogger(__name__)
# Default API endpoint
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
class LetzshopClientError(Exception):
"""Base exception for Letzshop client errors."""
def __init__(self, message: str, response_data: dict | None = None):
super().__init__(message)
self.message = message
self.response_data = response_data
class LetzshopAuthError(LetzshopClientError):
"""Raised when authentication fails."""
pass
class LetzshopAPIError(LetzshopClientError):
"""Raised when the API returns an error response."""
pass
class LetzshopConnectionError(LetzshopClientError):
"""Raised when connection to the API fails."""
pass
# ============================================================================
# GraphQL Queries
# ============================================================================
QUERY_SHIPMENTS = """
query GetShipments($state: ShipmentState) {
shipments(state: $state) {
nodes {
id
number
state
createdAt
updatedAt
order {
id
number
email
totalPrice {
amount
currency
}
lineItems {
nodes {
id
name
quantity
price {
amount
currency
}
}
}
shippingAddress {
firstName
lastName
company
address1
address2
city
zip
country
}
billingAddress {
firstName
lastName
company
address1
address2
city
zip
country
}
}
inventoryUnits {
nodes {
id
state
variant {
id
sku
name
}
}
}
tracking {
code
provider
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
QUERY_SHIPMENT_BY_ID = """
query GetShipment($id: ID!) {
node(id: $id) {
... on Shipment {
id
number
state
createdAt
updatedAt
order {
id
number
email
totalPrice {
amount
currency
}
}
inventoryUnits {
nodes {
id
state
variant {
id
sku
name
}
}
}
tracking {
code
provider
}
}
}
}
"""
# ============================================================================
# GraphQL Mutations
# ============================================================================
MUTATION_CONFIRM_INVENTORY_UNITS = """
mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) {
confirmInventoryUnits(input: $input) {
inventoryUnits {
id
state
}
errors {
id
code
message
}
}
}
"""
MUTATION_REJECT_INVENTORY_UNITS = """
mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) {
returnInventoryUnits(input: $input) {
inventoryUnits {
id
state
}
errors {
id
code
message
}
}
}
"""
MUTATION_SET_SHIPMENT_TRACKING = """
mutation SetShipmentTracking($input: SetShipmentTrackingInput!) {
setShipmentTracking(input: $input) {
shipment {
id
tracking {
code
provider
}
}
errors {
code
message
}
}
}
"""
class LetzshopClient:
"""
GraphQL client for Letzshop marketplace API.
Usage:
client = LetzshopClient(api_key="your-api-key")
shipments = client.get_shipments(state="unconfirmed")
"""
def __init__(
self,
api_key: str,
endpoint: str = DEFAULT_ENDPOINT,
timeout: int = 30,
):
"""
Initialize the Letzshop client.
Args:
api_key: The Letzshop API key (Bearer token).
endpoint: The GraphQL endpoint URL.
timeout: Request timeout in seconds.
"""
self.api_key = api_key
self.endpoint = endpoint
self.timeout = timeout
self._session: requests.Session | None = None
@property
def session(self) -> requests.Session:
"""Get or create a requests session."""
if self._session is None:
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
return self._session
def close(self) -> None:
"""Close the HTTP session."""
if self._session is not None:
self._session.close()
self._session = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def _execute(
self,
query: str,
variables: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Execute a GraphQL query or mutation.
Args:
query: The GraphQL query or mutation string.
variables: Optional variables for the query.
Returns:
The response data from the API.
Raises:
LetzshopAuthError: If authentication fails.
LetzshopAPIError: If the API returns an error.
LetzshopConnectionError: If the request fails.
"""
payload = {"query": query}
if variables:
payload["variables"] = variables
logger.debug(f"Executing GraphQL request to {self.endpoint}")
try:
response = self.session.post(
self.endpoint,
json=payload,
timeout=self.timeout,
)
except requests.exceptions.Timeout as e:
raise LetzshopConnectionError(f"Request timed out: {e}") from e
except requests.exceptions.ConnectionError as e:
raise LetzshopConnectionError(f"Connection failed: {e}") from e
except requests.exceptions.RequestException as e:
raise LetzshopConnectionError(f"Request failed: {e}") from e
# Handle HTTP-level errors
if response.status_code == 401:
raise LetzshopAuthError(
"Authentication failed. Please check your API key.",
response_data={"status_code": 401},
)
if response.status_code == 403:
raise LetzshopAuthError(
"Access forbidden. Your API key may not have the required permissions.",
response_data={"status_code": 403},
)
if response.status_code >= 500:
raise LetzshopAPIError(
f"Letzshop server error (HTTP {response.status_code})",
response_data={"status_code": response.status_code},
)
# Parse JSON response
try:
data = response.json()
except ValueError as e:
raise LetzshopAPIError(
f"Invalid JSON response: {response.text[:200]}"
) from e
# Check for GraphQL errors
if "errors" in data and data["errors"]:
error_messages = [
err.get("message", "Unknown error") for err in data["errors"]
]
raise LetzshopAPIError(
f"GraphQL errors: {'; '.join(error_messages)}",
response_data=data,
)
return data.get("data", {})
# ========================================================================
# Connection Testing
# ========================================================================
def test_connection(self) -> tuple[bool, float, str | None]:
"""
Test the connection to Letzshop API.
Returns:
Tuple of (success, response_time_ms, error_message).
"""
test_query = """
query TestConnection {
__typename
}
"""
start_time = time.time()
try:
self._execute(test_query)
elapsed_ms = (time.time() - start_time) * 1000
return True, elapsed_ms, None
except LetzshopClientError as e:
elapsed_ms = (time.time() - start_time) * 1000
return False, elapsed_ms, str(e)
# ========================================================================
# Shipment Queries
# ========================================================================
def get_shipments(
self,
state: str | None = None,
) -> list[dict[str, Any]]:
"""
Get shipments from Letzshop.
Args:
state: Optional state filter (e.g., "unconfirmed", "confirmed").
Returns:
List of shipment data dictionaries.
"""
variables = {}
if state:
variables["state"] = state
data = self._execute(QUERY_SHIPMENTS, variables)
shipments_data = data.get("shipments", {})
return shipments_data.get("nodes", [])
def get_unconfirmed_shipments(self) -> list[dict[str, Any]]:
"""Get all unconfirmed shipments."""
return self.get_shipments(state="unconfirmed")
def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None:
"""
Get a single shipment by its ID.
Args:
shipment_id: The Letzshop shipment ID.
Returns:
Shipment data or None if not found.
"""
data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id})
return data.get("node")
# ========================================================================
# Fulfillment Mutations
# ========================================================================
def confirm_inventory_units(
self,
inventory_unit_ids: list[str],
) -> dict[str, Any]:
"""
Confirm inventory units for fulfillment.
Args:
inventory_unit_ids: List of inventory unit IDs to confirm.
Returns:
Response data including confirmed units and any errors.
"""
variables = {
"input": {
"inventoryUnitIds": inventory_unit_ids,
}
}
data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables)
return data.get("confirmInventoryUnits", {})
def reject_inventory_units(
self,
inventory_unit_ids: list[str],
) -> dict[str, Any]:
"""
Reject/return inventory units.
Args:
inventory_unit_ids: List of inventory unit IDs to reject.
Returns:
Response data including rejected units and any errors.
"""
variables = {
"input": {
"inventoryUnitIds": inventory_unit_ids,
}
}
data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables)
return data.get("returnInventoryUnits", {})
def set_shipment_tracking(
self,
shipment_id: str,
tracking_code: str,
tracking_provider: str,
) -> dict[str, Any]:
"""
Set tracking information for a shipment.
Args:
shipment_id: The Letzshop shipment ID.
tracking_code: The tracking number.
tracking_provider: The carrier code (e.g., "dhl", "ups").
Returns:
Response data including updated shipment and any errors.
"""
variables = {
"input": {
"shipmentId": shipment_id,
"tracking": {
"code": tracking_code,
"provider": tracking_provider,
},
}
}
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
return data.get("setShipmentTracking", {})

View File

@@ -0,0 +1,413 @@
# app/services/letzshop/credentials.py
"""
Letzshop credentials management service.
Handles secure storage and retrieval of per-vendor Letzshop API credentials.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.utils.encryption import decrypt_value, encrypt_value, mask_api_key
from models.database.letzshop import VendorLetzshopCredentials
from .client import LetzshopClient
logger = logging.getLogger(__name__)
# Default Letzshop GraphQL endpoint
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
class CredentialsError(Exception):
"""Base exception for credentials errors."""
pass
class CredentialsNotFoundError(CredentialsError):
"""Raised when credentials are not found for a vendor."""
pass
class LetzshopCredentialsService:
"""
Service for managing Letzshop API credentials.
Provides secure storage and retrieval of encrypted API keys,
connection testing, and sync status updates.
"""
def __init__(self, db: Session):
"""
Initialize the credentials service.
Args:
db: SQLAlchemy database session.
"""
self.db = db
# ========================================================================
# CRUD Operations
# ========================================================================
def get_credentials(
self, vendor_id: int
) -> VendorLetzshopCredentials | None:
"""
Get Letzshop credentials for a vendor.
Args:
vendor_id: The vendor ID.
Returns:
VendorLetzshopCredentials or None if not found.
"""
return (
self.db.query(VendorLetzshopCredentials)
.filter(VendorLetzshopCredentials.vendor_id == vendor_id)
.first()
)
def get_credentials_or_raise(
self, vendor_id: int
) -> VendorLetzshopCredentials:
"""
Get Letzshop credentials for a vendor or raise an exception.
Args:
vendor_id: The vendor ID.
Returns:
VendorLetzshopCredentials.
Raises:
CredentialsNotFoundError: If credentials are not found.
"""
credentials = self.get_credentials(vendor_id)
if credentials is None:
raise CredentialsNotFoundError(
f"Letzshop credentials not found for vendor {vendor_id}"
)
return credentials
def create_credentials(
self,
vendor_id: int,
api_key: str,
api_endpoint: str | None = None,
auto_sync_enabled: bool = False,
sync_interval_minutes: int = 15,
) -> VendorLetzshopCredentials:
"""
Create Letzshop credentials for a vendor.
Args:
vendor_id: The vendor ID.
api_key: The Letzshop API key (will be encrypted).
api_endpoint: Custom API endpoint (optional).
auto_sync_enabled: Whether to enable automatic sync.
sync_interval_minutes: Sync interval in minutes.
Returns:
Created VendorLetzshopCredentials.
"""
# Encrypt the API key
encrypted_key = encrypt_value(api_key)
credentials = VendorLetzshopCredentials(
vendor_id=vendor_id,
api_key_encrypted=encrypted_key,
api_endpoint=api_endpoint or DEFAULT_ENDPOINT,
auto_sync_enabled=auto_sync_enabled,
sync_interval_minutes=sync_interval_minutes,
)
self.db.add(credentials)
self.db.commit()
self.db.refresh(credentials)
logger.info(f"Created Letzshop credentials for vendor {vendor_id}")
return credentials
def update_credentials(
self,
vendor_id: int,
api_key: str | None = None,
api_endpoint: str | None = None,
auto_sync_enabled: bool | None = None,
sync_interval_minutes: int | None = None,
) -> VendorLetzshopCredentials:
"""
Update Letzshop credentials for a vendor.
Args:
vendor_id: The vendor ID.
api_key: New API key (optional, will be encrypted if provided).
api_endpoint: New API endpoint (optional).
auto_sync_enabled: New auto-sync setting (optional).
sync_interval_minutes: New sync interval (optional).
Returns:
Updated VendorLetzshopCredentials.
Raises:
CredentialsNotFoundError: If credentials are not found.
"""
credentials = self.get_credentials_or_raise(vendor_id)
if api_key is not None:
credentials.api_key_encrypted = encrypt_value(api_key)
if api_endpoint is not None:
credentials.api_endpoint = api_endpoint
if auto_sync_enabled is not None:
credentials.auto_sync_enabled = auto_sync_enabled
if sync_interval_minutes is not None:
credentials.sync_interval_minutes = sync_interval_minutes
self.db.commit()
self.db.refresh(credentials)
logger.info(f"Updated Letzshop credentials for vendor {vendor_id}")
return credentials
def delete_credentials(self, vendor_id: int) -> bool:
"""
Delete Letzshop credentials for a vendor.
Args:
vendor_id: The vendor ID.
Returns:
True if deleted, False if not found.
"""
credentials = self.get_credentials(vendor_id)
if credentials is None:
return False
self.db.delete(credentials)
self.db.commit()
logger.info(f"Deleted Letzshop credentials for vendor {vendor_id}")
return True
def upsert_credentials(
self,
vendor_id: int,
api_key: str,
api_endpoint: str | None = None,
auto_sync_enabled: bool = False,
sync_interval_minutes: int = 15,
) -> VendorLetzshopCredentials:
"""
Create or update Letzshop credentials for a vendor.
Args:
vendor_id: The vendor ID.
api_key: The Letzshop API key (will be encrypted).
api_endpoint: Custom API endpoint (optional).
auto_sync_enabled: Whether to enable automatic sync.
sync_interval_minutes: Sync interval in minutes.
Returns:
Created or updated VendorLetzshopCredentials.
"""
existing = self.get_credentials(vendor_id)
if existing:
return self.update_credentials(
vendor_id=vendor_id,
api_key=api_key,
api_endpoint=api_endpoint,
auto_sync_enabled=auto_sync_enabled,
sync_interval_minutes=sync_interval_minutes,
)
return self.create_credentials(
vendor_id=vendor_id,
api_key=api_key,
api_endpoint=api_endpoint,
auto_sync_enabled=auto_sync_enabled,
sync_interval_minutes=sync_interval_minutes,
)
# ========================================================================
# Key Decryption and Client Creation
# ========================================================================
def get_decrypted_api_key(self, vendor_id: int) -> str:
"""
Get the decrypted API key for a vendor.
Args:
vendor_id: The vendor ID.
Returns:
Decrypted API key.
Raises:
CredentialsNotFoundError: If credentials are not found.
"""
credentials = self.get_credentials_or_raise(vendor_id)
return decrypt_value(credentials.api_key_encrypted)
def get_masked_api_key(self, vendor_id: int) -> str:
"""
Get a masked version of the API key for display.
Args:
vendor_id: The vendor ID.
Returns:
Masked API key (e.g., "sk-a***************").
Raises:
CredentialsNotFoundError: If credentials are not found.
"""
api_key = self.get_decrypted_api_key(vendor_id)
return mask_api_key(api_key)
def create_client(self, vendor_id: int) -> LetzshopClient:
"""
Create a Letzshop client for a vendor.
Args:
vendor_id: The vendor ID.
Returns:
Configured LetzshopClient.
Raises:
CredentialsNotFoundError: If credentials are not found.
"""
credentials = self.get_credentials_or_raise(vendor_id)
api_key = decrypt_value(credentials.api_key_encrypted)
return LetzshopClient(
api_key=api_key,
endpoint=credentials.api_endpoint,
)
# ========================================================================
# Connection Testing
# ========================================================================
def test_connection(
self, vendor_id: int
) -> tuple[bool, float | None, str | None]:
"""
Test the connection for a vendor's credentials.
Args:
vendor_id: The vendor ID.
Returns:
Tuple of (success, response_time_ms, error_message).
"""
try:
with self.create_client(vendor_id) as client:
return client.test_connection()
except CredentialsNotFoundError:
return False, None, "Letzshop credentials not configured"
except Exception as e:
logger.error(f"Connection test failed for vendor {vendor_id}: {e}")
return False, None, str(e)
def test_api_key(
self,
api_key: str,
api_endpoint: str | None = None,
) -> tuple[bool, float | None, str | None]:
"""
Test an API key without saving it.
Args:
api_key: The API key to test.
api_endpoint: Optional custom endpoint.
Returns:
Tuple of (success, response_time_ms, error_message).
"""
try:
with LetzshopClient(
api_key=api_key,
endpoint=api_endpoint or DEFAULT_ENDPOINT,
) as client:
return client.test_connection()
except Exception as e:
logger.error(f"API key test failed: {e}")
return False, None, str(e)
# ========================================================================
# Sync Status Updates
# ========================================================================
def update_sync_status(
self,
vendor_id: int,
status: str,
error: str | None = None,
) -> VendorLetzshopCredentials | None:
"""
Update the last sync status for a vendor.
Args:
vendor_id: The vendor ID.
status: Sync status (success, failed, partial).
error: Error message if sync failed.
Returns:
Updated credentials or None if not found.
"""
credentials = self.get_credentials(vendor_id)
if credentials is None:
return None
credentials.last_sync_at = datetime.now(timezone.utc)
credentials.last_sync_status = status
credentials.last_sync_error = error
self.db.commit()
self.db.refresh(credentials)
return credentials
# ========================================================================
# Status Helpers
# ========================================================================
def is_configured(self, vendor_id: int) -> bool:
"""Check if Letzshop is configured for a vendor."""
return self.get_credentials(vendor_id) is not None
def get_status(self, vendor_id: int) -> dict:
"""
Get the Letzshop integration status for a vendor.
Args:
vendor_id: The vendor ID.
Returns:
Status dictionary with configuration and sync info.
"""
credentials = self.get_credentials(vendor_id)
if credentials is None:
return {
"is_configured": False,
"is_connected": False,
"last_sync_at": None,
"last_sync_status": None,
"auto_sync_enabled": False,
}
return {
"is_configured": True,
"is_connected": credentials.last_sync_status == "success",
"last_sync_at": credentials.last_sync_at,
"last_sync_status": credentials.last_sync_status,
"auto_sync_enabled": credentials.auto_sync_enabled,
}

View File

@@ -0,0 +1,319 @@
# app/services/letzshop/order_service.py
"""
Letzshop order service for handling order-related database operations.
This service moves database queries out of the API layer to comply with
architecture rules (API-002: endpoints should not contain business logic).
"""
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from models.database.letzshop import (
LetzshopFulfillmentQueue,
LetzshopOrder,
LetzshopSyncLog,
VendorLetzshopCredentials,
)
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class VendorNotFoundError(Exception):
"""Raised when a vendor is not found."""
pass
class OrderNotFoundError(Exception):
"""Raised when a Letzshop order is not found."""
pass
class LetzshopOrderService:
"""Service for Letzshop order database operations."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# Vendor Operations
# =========================================================================
def get_vendor(self, vendor_id: int) -> Vendor | None:
"""Get vendor by ID."""
return self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
def get_vendor_or_raise(self, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise VendorNotFoundError."""
vendor = self.get_vendor(vendor_id)
if vendor is None:
raise VendorNotFoundError(f"Vendor with ID {vendor_id} not found")
return vendor
def list_vendors_with_letzshop_status(
self,
skip: int = 0,
limit: int = 100,
configured_only: bool = False,
) -> tuple[list[dict[str, Any]], int]:
"""
List vendors with their Letzshop integration status.
Returns a tuple of (vendor_overviews, total_count).
"""
# Build query
query = self.db.query(Vendor).filter(Vendor.is_active == True) # noqa: E712
if configured_only:
query = query.join(
VendorLetzshopCredentials,
Vendor.id == VendorLetzshopCredentials.vendor_id,
)
# Get total count
total = query.count()
# Get vendors
vendors = query.order_by(Vendor.name).offset(skip).limit(limit).all()
# Build response with Letzshop status
vendor_overviews = []
for vendor in vendors:
# Get credentials
credentials = (
self.db.query(VendorLetzshopCredentials)
.filter(VendorLetzshopCredentials.vendor_id == vendor.id)
.first()
)
# Get order counts
pending_orders = 0
total_orders = 0
if credentials:
pending_orders = (
self.db.query(func.count(LetzshopOrder.id))
.filter(
LetzshopOrder.vendor_id == vendor.id,
LetzshopOrder.sync_status == "pending",
)
.scalar()
or 0
)
total_orders = (
self.db.query(func.count(LetzshopOrder.id))
.filter(LetzshopOrder.vendor_id == vendor.id)
.scalar()
or 0
)
vendor_overviews.append({
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_code": vendor.vendor_code,
"is_configured": credentials is not None,
"auto_sync_enabled": credentials.auto_sync_enabled if credentials else False,
"last_sync_at": credentials.last_sync_at if credentials else None,
"last_sync_status": credentials.last_sync_status if credentials else None,
"pending_orders": pending_orders,
"total_orders": total_orders,
})
return vendor_overviews, total
# =========================================================================
# Order Operations
# =========================================================================
def get_order(self, vendor_id: int, order_id: int) -> LetzshopOrder | None:
"""Get a Letzshop order by ID for a specific vendor."""
return (
self.db.query(LetzshopOrder)
.filter(
LetzshopOrder.id == order_id,
LetzshopOrder.vendor_id == vendor_id,
)
.first()
)
def get_order_or_raise(self, vendor_id: int, order_id: int) -> LetzshopOrder:
"""Get a Letzshop order or raise OrderNotFoundError."""
order = self.get_order(vendor_id, order_id)
if order is None:
raise OrderNotFoundError(f"Letzshop order {order_id} not found")
return order
def get_order_by_shipment_id(
self, vendor_id: int, shipment_id: str
) -> LetzshopOrder | None:
"""Get a Letzshop order by shipment ID."""
return (
self.db.query(LetzshopOrder)
.filter(
LetzshopOrder.vendor_id == vendor_id,
LetzshopOrder.letzshop_shipment_id == shipment_id,
)
.first()
)
def list_orders(
self,
vendor_id: int,
skip: int = 0,
limit: int = 50,
sync_status: str | None = None,
letzshop_state: str | None = None,
) -> tuple[list[LetzshopOrder], int]:
"""
List Letzshop orders for a vendor.
Returns a tuple of (orders, total_count).
"""
query = self.db.query(LetzshopOrder).filter(
LetzshopOrder.vendor_id == vendor_id
)
if sync_status:
query = query.filter(LetzshopOrder.sync_status == sync_status)
if letzshop_state:
query = query.filter(LetzshopOrder.letzshop_state == letzshop_state)
total = query.count()
orders = (
query.order_by(LetzshopOrder.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return orders, total
def create_order(
self,
vendor_id: int,
shipment_data: dict[str, Any],
) -> LetzshopOrder:
"""Create a new Letzshop order from shipment data."""
order_data = shipment_data.get("order", {})
order = LetzshopOrder(
vendor_id=vendor_id,
letzshop_order_id=order_data.get("id", ""),
letzshop_shipment_id=shipment_data["id"],
letzshop_order_number=order_data.get("number"),
letzshop_state=shipment_data.get("state"),
customer_email=order_data.get("email"),
total_amount=str(
order_data.get("totalPrice", {}).get("amount", "")
),
currency=order_data.get("totalPrice", {}).get("currency", "EUR"),
raw_order_data=shipment_data,
inventory_units=[
{"id": u["id"], "state": u["state"]}
for u in shipment_data.get("inventoryUnits", {}).get("nodes", [])
],
sync_status="pending",
)
self.db.add(order)
return order
def update_order_from_shipment(
self,
order: LetzshopOrder,
shipment_data: dict[str, Any],
) -> LetzshopOrder:
"""Update an existing order from shipment data."""
order.letzshop_state = shipment_data.get("state")
order.raw_order_data = shipment_data
return order
def mark_order_confirmed(self, order: LetzshopOrder) -> LetzshopOrder:
"""Mark an order as confirmed."""
order.confirmed_at = datetime.now(timezone.utc)
order.sync_status = "confirmed"
return order
def mark_order_rejected(self, order: LetzshopOrder) -> LetzshopOrder:
"""Mark an order as rejected."""
order.rejected_at = datetime.now(timezone.utc)
order.sync_status = "rejected"
return order
def set_order_tracking(
self,
order: LetzshopOrder,
tracking_number: str,
tracking_carrier: str,
) -> LetzshopOrder:
"""Set tracking information for an order."""
order.tracking_number = tracking_number
order.tracking_carrier = tracking_carrier
order.tracking_set_at = datetime.now(timezone.utc)
order.sync_status = "shipped"
return order
# =========================================================================
# Sync Log Operations
# =========================================================================
def list_sync_logs(
self,
vendor_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list[LetzshopSyncLog], int]:
"""
List sync logs for a vendor.
Returns a tuple of (logs, total_count).
"""
query = self.db.query(LetzshopSyncLog).filter(
LetzshopSyncLog.vendor_id == vendor_id
)
total = query.count()
logs = (
query.order_by(LetzshopSyncLog.started_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return logs, total
# =========================================================================
# Fulfillment Queue Operations
# =========================================================================
def list_fulfillment_queue(
self,
vendor_id: int,
skip: int = 0,
limit: int = 50,
status: str | None = None,
) -> tuple[list[LetzshopFulfillmentQueue], int]:
"""
List fulfillment queue items for a vendor.
Returns a tuple of (items, total_count).
"""
query = self.db.query(LetzshopFulfillmentQueue).filter(
LetzshopFulfillmentQueue.vendor_id == vendor_id
)
if status:
query = query.filter(LetzshopFulfillmentQueue.status == status)
total = query.count()
items = (
query.order_by(LetzshopFulfillmentQueue.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return items, total

187
app/utils/encryption.py Normal file
View File

@@ -0,0 +1,187 @@
# app/utils/encryption.py
"""
Encryption utilities for sensitive data storage.
Uses Fernet symmetric encryption with key derivation from the JWT secret.
Provides secure storage for API keys and other sensitive credentials.
"""
import base64
import logging
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from app.core.config import settings
logger = logging.getLogger(__name__)
# Salt for key derivation - fixed to ensure consistent encryption/decryption
# In production, this should be stored securely and not changed
_ENCRYPTION_SALT = b"wizamart_encryption_salt_v1"
class EncryptionError(Exception):
"""Raised when encryption or decryption fails."""
pass
class EncryptionService:
"""
Service for encrypting and decrypting sensitive data.
Uses Fernet symmetric encryption with a key derived from the application's
JWT secret key. This ensures that encrypted data can only be decrypted
by the same application instance with the same secret.
"""
def __init__(self, secret_key: str | None = None):
"""
Initialize the encryption service.
Args:
secret_key: The secret key to derive the encryption key from.
Defaults to the JWT secret key from settings.
"""
if secret_key is None:
secret_key = settings.jwt_secret_key
self._fernet = self._create_fernet(secret_key)
def _create_fernet(self, secret_key: str) -> Fernet:
"""
Create a Fernet instance with a derived key.
Uses PBKDF2 to derive a 32-byte key from the secret,
then encodes it as base64 for Fernet.
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=_ENCRYPTION_SALT,
iterations=100000,
)
derived_key = kdf.derive(secret_key.encode())
fernet_key = base64.urlsafe_b64encode(derived_key)
return Fernet(fernet_key)
def encrypt(self, plaintext: str) -> str:
"""
Encrypt a plaintext string.
Args:
plaintext: The string to encrypt.
Returns:
Base64-encoded ciphertext string.
Raises:
EncryptionError: If encryption fails.
"""
if not plaintext:
raise EncryptionError("Cannot encrypt empty string")
try:
ciphertext = self._fernet.encrypt(plaintext.encode())
return ciphertext.decode()
except Exception as e:
logger.error(f"Encryption failed: {e}")
raise EncryptionError(f"Failed to encrypt data: {e}") from e
def decrypt(self, ciphertext: str) -> str:
"""
Decrypt a ciphertext string.
Args:
ciphertext: Base64-encoded ciphertext to decrypt.
Returns:
Decrypted plaintext string.
Raises:
EncryptionError: If decryption fails (invalid token or corrupted data).
"""
if not ciphertext:
raise EncryptionError("Cannot decrypt empty string")
try:
plaintext = self._fernet.decrypt(ciphertext.encode())
return plaintext.decode()
except InvalidToken as e:
logger.error("Decryption failed: Invalid token")
raise EncryptionError(
"Failed to decrypt data: Invalid or corrupted ciphertext"
) from e
except Exception as e:
logger.error(f"Decryption failed: {e}")
raise EncryptionError(f"Failed to decrypt data: {e}") from e
def is_valid_ciphertext(self, ciphertext: str) -> bool:
"""
Check if a string is valid ciphertext that can be decrypted.
Args:
ciphertext: String to validate.
Returns:
True if the string can be decrypted, False otherwise.
"""
try:
self.decrypt(ciphertext)
return True
except EncryptionError:
return False
# Singleton instance using the JWT secret key
encryption_service = EncryptionService()
def encrypt_value(value: str) -> str:
"""
Convenience function to encrypt a value using the default service.
Args:
value: The string to encrypt.
Returns:
Encrypted string.
"""
return encryption_service.encrypt(value)
def decrypt_value(value: str) -> str:
"""
Convenience function to decrypt a value using the default service.
Args:
value: The encrypted string to decrypt.
Returns:
Decrypted string.
"""
return encryption_service.decrypt(value)
def mask_api_key(api_key: str, visible_chars: int = 4) -> str:
"""
Mask an API key for display purposes.
Shows only the first few characters, replacing the rest with asterisks.
Args:
api_key: The API key to mask.
visible_chars: Number of characters to show at the start.
Returns:
Masked API key string (e.g., "sk-a***************").
"""
if not api_key:
return ""
if len(api_key) <= visible_chars:
return "*" * len(api_key)
return api_key[:visible_chars] + "*" * (len(api_key) - visible_chars)