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

@@ -0,0 +1,179 @@
"""add_letzshop_integration_tables
Revision ID: 987b4ecfa503
Revises: 82ea1b4a3ccb
Create Date: 2025-12-13
This migration adds:
- vendor_letzshop_credentials: Per-vendor encrypted API key storage
- letzshop_orders: Track imported orders with external IDs
- letzshop_fulfillment_queue: Queue outbound operations with retry
- letzshop_sync_logs: Audit trail for sync operations
- Adds channel fields to orders table for multi-marketplace support
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '987b4ecfa503'
down_revision: Union[str, None] = '82ea1b4a3ccb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add channel fields to orders table
op.add_column('orders', sa.Column('channel', sa.String(length=50), nullable=True, server_default='direct'))
op.add_column('orders', sa.Column('external_order_id', sa.String(length=100), nullable=True))
op.add_column('orders', sa.Column('external_channel_data', sa.JSON(), nullable=True))
op.create_index(op.f('ix_orders_channel'), 'orders', ['channel'], unique=False)
op.create_index(op.f('ix_orders_external_order_id'), 'orders', ['external_order_id'], unique=False)
# Create vendor_letzshop_credentials table
op.create_table('vendor_letzshop_credentials',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('api_key_encrypted', sa.Text(), nullable=False),
sa.Column('api_endpoint', sa.String(length=255), server_default='https://letzshop.lu/graphql', nullable=True),
sa.Column('auto_sync_enabled', sa.Boolean(), server_default='0', nullable=True),
sa.Column('sync_interval_minutes', sa.Integer(), server_default='15', nullable=True),
sa.Column('last_sync_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_sync_status', sa.String(length=50), nullable=True),
sa.Column('last_sync_error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('vendor_id')
)
op.create_index(op.f('ix_vendor_letzshop_credentials_id'), 'vendor_letzshop_credentials', ['id'], unique=False)
op.create_index(op.f('ix_vendor_letzshop_credentials_vendor_id'), 'vendor_letzshop_credentials', ['vendor_id'], unique=True)
# Create letzshop_orders table
op.create_table('letzshop_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('letzshop_order_id', sa.String(length=100), nullable=False),
sa.Column('letzshop_shipment_id', sa.String(length=100), nullable=True),
sa.Column('letzshop_order_number', sa.String(length=100), nullable=True),
sa.Column('local_order_id', sa.Integer(), nullable=True),
sa.Column('letzshop_state', sa.String(length=50), nullable=True),
sa.Column('customer_email', sa.String(length=255), nullable=True),
sa.Column('customer_name', sa.String(length=255), nullable=True),
sa.Column('total_amount', sa.String(length=50), nullable=True),
sa.Column('currency', sa.String(length=10), server_default='EUR', nullable=True),
sa.Column('raw_order_data', sa.JSON(), nullable=True),
sa.Column('inventory_units', sa.JSON(), nullable=True),
sa.Column('sync_status', sa.String(length=50), server_default='pending', nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('sync_error', sa.Text(), nullable=True),
sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('rejected_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('tracking_set_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('tracking_number', sa.String(length=100), nullable=True),
sa.Column('tracking_carrier', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['local_order_id'], ['orders.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_letzshop_orders_id'), 'letzshop_orders', ['id'], unique=False)
op.create_index(op.f('ix_letzshop_orders_letzshop_order_id'), 'letzshop_orders', ['letzshop_order_id'], unique=False)
op.create_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), 'letzshop_orders', ['letzshop_shipment_id'], unique=False)
op.create_index(op.f('ix_letzshop_orders_vendor_id'), 'letzshop_orders', ['vendor_id'], unique=False)
op.create_index('idx_letzshop_order_vendor', 'letzshop_orders', ['vendor_id', 'letzshop_order_id'], unique=False)
op.create_index('idx_letzshop_order_state', 'letzshop_orders', ['vendor_id', 'letzshop_state'], unique=False)
op.create_index('idx_letzshop_order_sync', 'letzshop_orders', ['vendor_id', 'sync_status'], unique=False)
# Create letzshop_fulfillment_queue table
op.create_table('letzshop_fulfillment_queue',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('letzshop_order_id', sa.Integer(), nullable=False),
sa.Column('operation', sa.String(length=50), nullable=False),
sa.Column('payload', sa.JSON(), nullable=False),
sa.Column('status', sa.String(length=50), server_default='pending', nullable=True),
sa.Column('attempts', sa.Integer(), server_default='0', nullable=True),
sa.Column('max_attempts', sa.Integer(), server_default='3', nullable=True),
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('response_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['letzshop_order_id'], ['letzshop_orders.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_letzshop_fulfillment_queue_id'), 'letzshop_fulfillment_queue', ['id'], unique=False)
op.create_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), 'letzshop_fulfillment_queue', ['vendor_id'], unique=False)
op.create_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue', ['status', 'vendor_id'], unique=False)
op.create_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue', ['status', 'next_retry_at'], unique=False)
# Create letzshop_sync_logs table
op.create_table('letzshop_sync_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('operation_type', sa.String(length=50), nullable=False),
sa.Column('direction', sa.String(length=10), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('records_processed', sa.Integer(), server_default='0', nullable=True),
sa.Column('records_succeeded', sa.Integer(), server_default='0', nullable=True),
sa.Column('records_failed', sa.Integer(), server_default='0', nullable=True),
sa.Column('error_details', sa.JSON(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('triggered_by', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_letzshop_sync_logs_id'), 'letzshop_sync_logs', ['id'], unique=False)
op.create_index(op.f('ix_letzshop_sync_logs_vendor_id'), 'letzshop_sync_logs', ['vendor_id'], unique=False)
op.create_index('idx_sync_log_vendor_type', 'letzshop_sync_logs', ['vendor_id', 'operation_type'], unique=False)
op.create_index('idx_sync_log_vendor_date', 'letzshop_sync_logs', ['vendor_id', 'started_at'], unique=False)
def downgrade() -> None:
# Drop letzshop_sync_logs table
op.drop_index('idx_sync_log_vendor_date', table_name='letzshop_sync_logs')
op.drop_index('idx_sync_log_vendor_type', table_name='letzshop_sync_logs')
op.drop_index(op.f('ix_letzshop_sync_logs_vendor_id'), table_name='letzshop_sync_logs')
op.drop_index(op.f('ix_letzshop_sync_logs_id'), table_name='letzshop_sync_logs')
op.drop_table('letzshop_sync_logs')
# Drop letzshop_fulfillment_queue table
op.drop_index('idx_fulfillment_queue_retry', table_name='letzshop_fulfillment_queue')
op.drop_index('idx_fulfillment_queue_status', table_name='letzshop_fulfillment_queue')
op.drop_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), table_name='letzshop_fulfillment_queue')
op.drop_index(op.f('ix_letzshop_fulfillment_queue_id'), table_name='letzshop_fulfillment_queue')
op.drop_table('letzshop_fulfillment_queue')
# Drop letzshop_orders table
op.drop_index('idx_letzshop_order_sync', table_name='letzshop_orders')
op.drop_index('idx_letzshop_order_state', table_name='letzshop_orders')
op.drop_index('idx_letzshop_order_vendor', table_name='letzshop_orders')
op.drop_index(op.f('ix_letzshop_orders_vendor_id'), table_name='letzshop_orders')
op.drop_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), table_name='letzshop_orders')
op.drop_index(op.f('ix_letzshop_orders_letzshop_order_id'), table_name='letzshop_orders')
op.drop_index(op.f('ix_letzshop_orders_id'), table_name='letzshop_orders')
op.drop_table('letzshop_orders')
# Drop vendor_letzshop_credentials table
op.drop_index(op.f('ix_vendor_letzshop_credentials_vendor_id'), table_name='vendor_letzshop_credentials')
op.drop_index(op.f('ix_vendor_letzshop_credentials_id'), table_name='vendor_letzshop_credentials')
op.drop_table('vendor_letzshop_credentials')
# Drop channel fields from orders table
op.drop_index(op.f('ix_orders_external_order_id'), table_name='orders')
op.drop_index(op.f('ix_orders_channel'), table_name='orders')
op.drop_column('orders', 'external_channel_data')
op.drop_column('orders', 'external_order_id')
op.drop_column('orders', 'channel')

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)

View File

@@ -0,0 +1,542 @@
# Letzshop Order Integration Guide
Complete guide for bidirectional order management with Letzshop marketplace via GraphQL API.
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Setup and Configuration](#setup-and-configuration)
- [Order Import](#order-import)
- [Fulfillment Operations](#fulfillment-operations)
- [API Reference](#api-reference)
- [Database Models](#database-models)
- [Troubleshooting](#troubleshooting)
---
## Overview
The Letzshop Order Integration provides bidirectional synchronization with Letzshop marketplace:
- **Order Import**: Fetch unconfirmed orders from Letzshop via GraphQL
- **Order Confirmation**: Confirm or reject inventory units
- **Tracking Updates**: Set shipment tracking information
- **Audit Trail**: Complete logging of all sync operations
### Key Features
- **Encrypted Credentials**: API keys stored with Fernet encryption
- **Per-Vendor Configuration**: Each vendor manages their own Letzshop connection
- **Admin Oversight**: Platform admins can manage any vendor's integration
- **Queue-Based Fulfillment**: Retry logic for failed operations
- **Multi-Channel Support**: Orders tracked with channel attribution
---
## Architecture
### System Components
```
┌─────────────────────────────────────────┐
│ Frontend Interfaces │
├─────────────────────────────────────────┤
│ Vendor Portal Admin Portal │
│ /vendor/letzshop /admin/letzshop │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ API Layer │
├─────────────────────────────────────────┤
│ /api/v1/vendor/letzshop/* │
│ /api/v1/admin/letzshop/* │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Service Layer │
├─────────────────────────────────────────┤
│ LetzshopClient CredentialsService│
│ (GraphQL) (Encryption) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Data Layer │
├─────────────────────────────────────────┤
│ VendorLetzshopCredentials │
│ LetzshopOrder │
│ LetzshopFulfillmentQueue │
│ LetzshopSyncLog │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Letzshop GraphQL API │
│ https://letzshop.lu/graphql │
└─────────────────────────────────────────┘
```
### Data Flow
1. **Credentials Setup**: Vendor/Admin stores encrypted API key
2. **Order Import**: System fetches unconfirmed shipments from Letzshop
3. **Order Processing**: Orders stored locally with Letzshop IDs
4. **Fulfillment**: Vendor confirms/rejects orders, sets tracking
5. **Sync Back**: Operations sent to Letzshop via GraphQL mutations
---
## Setup and Configuration
### Prerequisites
- Letzshop API key (obtained from Letzshop merchant portal)
- Active vendor account on the platform
### Step 1: Configure API Credentials
#### Via Vendor Portal
1. Navigate to **Settings > Letzshop Integration**
2. Enter your Letzshop API key
3. Click **Test Connection** to verify
4. Enable **Auto-Sync** if desired (optional)
5. Click **Save**
#### Via Admin Portal
1. Navigate to **Marketplace > Letzshop**
2. Select the vendor from the list
3. Click **Configure Credentials**
4. Enter the API key
5. Click **Save & Test**
### Step 2: Test Connection
```bash
# Test connection via API
curl -X POST /api/v1/vendor/letzshop/test \
-H "Authorization: Bearer $TOKEN"
```
Response:
```json
{
"success": true,
"message": "Connection successful",
"response_time_ms": 245.5
}
```
### Configuration Options
| Setting | Default | Description |
|---------|---------|-------------|
| `api_endpoint` | `https://letzshop.lu/graphql` | GraphQL endpoint URL |
| `auto_sync_enabled` | `false` | Enable automatic order sync |
| `sync_interval_minutes` | `15` | Auto-sync interval (5-1440 minutes) |
---
## Order Import
### Manual Import
Import orders on-demand via the vendor portal or API:
```bash
# Trigger order import
curl -X POST /api/v1/vendor/letzshop/orders/import \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"operation": "order_import"}'
```
Response:
```json
{
"success": true,
"message": "Import completed: 5 imported, 2 updated",
"orders_imported": 5,
"orders_updated": 2,
"errors": []
}
```
### What Gets Imported
The import fetches **unconfirmed shipments** from Letzshop containing:
- Order ID and number
- Customer email and name
- Order total and currency
- Inventory units (products to fulfill)
- Shipping/billing addresses
- Current order state
### Order States
| Letzshop State | Description |
|----------------|-------------|
| `unconfirmed` | Awaiting vendor confirmation |
| `confirmed` | Vendor confirmed, ready to ship |
| `shipped` | Tracking number set |
| `delivered` | Delivery confirmed |
| `returned` | Items returned |
### Sync Status
Local orders track their sync status:
| Status | Description |
|--------|-------------|
| `pending` | Imported, awaiting action |
| `confirmed` | Confirmed with Letzshop |
| `rejected` | Rejected with Letzshop |
| `shipped` | Tracking set with Letzshop |
---
## Fulfillment Operations
### Confirm Order
Confirm that you can fulfill the order:
```bash
# Confirm all inventory units in an order
curl -X POST /api/v1/vendor/letzshop/orders/{order_id}/confirm \
-H "Authorization: Bearer $TOKEN"
# Or confirm specific units
curl -X POST /api/v1/vendor/letzshop/orders/{order_id}/confirm \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"inventory_unit_ids": ["unit_abc123", "unit_def456"]}'
```
### Reject Order
Reject order if you cannot fulfill:
```bash
curl -X POST /api/v1/vendor/letzshop/orders/{order_id}/reject \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "Out of stock"}'
```
### Set Tracking
Add tracking information for shipment:
```bash
curl -X POST /api/v1/vendor/letzshop/orders/{order_id}/tracking \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tracking_number": "1Z999AA10123456784",
"tracking_carrier": "ups"
}'
```
Supported carriers: `dhl`, `ups`, `fedex`, `post_lu`, etc.
---
## API Reference
### Vendor Endpoints
Base path: `/api/v1/vendor/letzshop`
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/status` | Get integration status |
| GET | `/credentials` | Get credentials (API key masked) |
| POST | `/credentials` | Create/update credentials |
| PATCH | `/credentials` | Partial update credentials |
| DELETE | `/credentials` | Remove credentials |
| POST | `/test` | Test stored credentials |
| POST | `/test-key` | Test API key without saving |
| GET | `/orders` | List Letzshop orders |
| GET | `/orders/{id}` | Get order details |
| POST | `/orders/import` | Import orders from Letzshop |
| POST | `/orders/{id}/confirm` | Confirm order |
| POST | `/orders/{id}/reject` | Reject order |
| POST | `/orders/{id}/tracking` | Set tracking info |
| GET | `/logs` | List sync logs |
| GET | `/queue` | List fulfillment queue |
### Admin Endpoints
Base path: `/api/v1/admin/letzshop`
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/vendors` | List vendors with Letzshop status |
| GET | `/vendors/{id}/credentials` | Get vendor credentials |
| POST | `/vendors/{id}/credentials` | Set vendor credentials |
| PATCH | `/vendors/{id}/credentials` | Update vendor credentials |
| DELETE | `/vendors/{id}/credentials` | Delete vendor credentials |
| POST | `/vendors/{id}/test` | Test vendor connection |
| POST | `/test` | Test any API key |
| GET | `/vendors/{id}/orders` | List vendor's Letzshop orders |
| POST | `/vendors/{id}/sync` | Trigger sync for vendor |
### Response Schemas
#### Credentials Response
```json
{
"id": 1,
"vendor_id": 5,
"api_key_masked": "letz****",
"api_endpoint": "https://letzshop.lu/graphql",
"auto_sync_enabled": false,
"sync_interval_minutes": 15,
"last_sync_at": "2025-01-15T10:30:00Z",
"last_sync_status": "success",
"last_sync_error": null,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
```
#### Order Response
```json
{
"id": 123,
"vendor_id": 5,
"letzshop_order_id": "gid://letzshop/Order/12345",
"letzshop_shipment_id": "gid://letzshop/Shipment/67890",
"letzshop_order_number": "LS-2025-001234",
"letzshop_state": "unconfirmed",
"customer_email": "customer@example.com",
"customer_name": "John Doe",
"total_amount": "99.99",
"currency": "EUR",
"sync_status": "pending",
"inventory_units": [
{"id": "gid://letzshop/InventoryUnit/111", "state": "unconfirmed"}
],
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z"
}
```
---
## Database Models
### VendorLetzshopCredentials
Stores encrypted API credentials per vendor.
```python
class VendorLetzshopCredentials(Base):
__tablename__ = "vendor_letzshop_credentials"
id: int # Primary key
vendor_id: int # FK to vendors (unique)
api_key_encrypted: str # Fernet encrypted API key
api_endpoint: str # GraphQL endpoint URL
auto_sync_enabled: bool # Enable auto-sync
sync_interval_minutes: int # Sync interval
last_sync_at: datetime # Last sync timestamp
last_sync_status: str # success, failed, partial
last_sync_error: str # Error message if failed
```
### LetzshopOrder
Tracks imported orders from Letzshop.
```python
class LetzshopOrder(Base):
__tablename__ = "letzshop_orders"
id: int # Primary key
vendor_id: int # FK to vendors
letzshop_order_id: str # Letzshop order GID
letzshop_shipment_id: str # Letzshop shipment GID
letzshop_order_number: str # Human-readable order number
local_order_id: int # FK to orders (if imported locally)
letzshop_state: str # Current Letzshop state
customer_email: str # Customer email
customer_name: str # Customer name
total_amount: str # Order total
currency: str # Currency code
raw_order_data: JSON # Full order data from Letzshop
inventory_units: JSON # List of inventory units
sync_status: str # pending, confirmed, rejected, shipped
tracking_number: str # Tracking number (if set)
tracking_carrier: str # Carrier code
```
### LetzshopFulfillmentQueue
Queue for outbound operations with retry logic.
```python
class LetzshopFulfillmentQueue(Base):
__tablename__ = "letzshop_fulfillment_queue"
id: int # Primary key
vendor_id: int # FK to vendors
letzshop_order_id: int # FK to letzshop_orders
operation: str # confirm, reject, set_tracking
payload: JSON # Operation data
status: str # pending, processing, completed, failed
attempts: int # Retry count
max_attempts: int # Max retries (default 3)
error_message: str # Last error if failed
response_data: JSON # Response from Letzshop
```
### LetzshopSyncLog
Audit trail for all sync operations.
```python
class LetzshopSyncLog(Base):
__tablename__ = "letzshop_sync_logs"
id: int # Primary key
vendor_id: int # FK to vendors
operation_type: str # order_import, confirm, etc.
direction: str # inbound, outbound
status: str # success, failed, partial
records_processed: int # Total records
records_succeeded: int # Successful records
records_failed: int # Failed records
error_details: JSON # Detailed error info
started_at: datetime # Operation start time
completed_at: datetime # Operation end time
duration_seconds: int # Total duration
triggered_by: str # user_id, scheduler, webhook
```
---
## Security
### API Key Encryption
API keys are encrypted using Fernet symmetric encryption:
```python
from app.utils.encryption import encrypt_value, decrypt_value
# Encrypt before storing
encrypted_key = encrypt_value(api_key)
# Decrypt when needed
api_key = decrypt_value(encrypted_key)
```
The encryption key is derived from the application's `jwt_secret_key` using PBKDF2.
### Access Control
- **Vendors**: Can only manage their own Letzshop integration
- **Admins**: Can manage any vendor's integration
- **API Keys**: Never returned in plain text (always masked)
---
## Troubleshooting
### Connection Failed
**Symptoms**: "Connection failed" error when testing
**Possible Causes**:
- Invalid API key
- API key expired
- Network issues
- Letzshop service unavailable
**Solutions**:
1. Verify API key in Letzshop merchant portal
2. Regenerate API key if expired
3. Check network connectivity
4. Check Letzshop status page
### Orders Not Importing
**Symptoms**: Import runs but no orders appear
**Possible Causes**:
- No unconfirmed orders in Letzshop
- API key doesn't have required permissions
- Orders already imported
**Solutions**:
1. Check Letzshop dashboard for unconfirmed orders
2. Verify API key has order read permissions
3. Check existing orders with `sync_status: pending`
### Fulfillment Failed
**Symptoms**: Confirm/reject/tracking operations fail
**Possible Causes**:
- Order already processed
- Invalid inventory unit IDs
- API permission issues
**Solutions**:
1. Check order state in Letzshop
2. Verify inventory unit IDs are correct
3. Check fulfillment queue for retry status
4. Review error message in response
### Sync Logs
Check sync logs for detailed operation history:
```bash
curl -X GET /api/v1/vendor/letzshop/logs \
-H "Authorization: Bearer $TOKEN"
```
---
## Best Practices
### For Vendors
1. **Test connection** after setting up credentials
2. **Import orders regularly** (or enable auto-sync)
3. **Confirm orders promptly** to avoid delays
4. **Set tracking** as soon as shipment is dispatched
5. **Monitor sync logs** for any failures
### For Admins
1. **Review vendor status** regularly via admin dashboard
2. **Assist vendors** with connection issues
3. **Monitor sync logs** for platform-wide issues
4. **Set up alerts** for failed syncs (optional)
---
## Related Documentation
- [Marketplace Integration (CSV Import)](marketplace-integration.md)
- [Vendor RBAC](../backend/vendor-rbac.md)
- [Admin Integration Guide](../backend/admin-integration-guide.md)
- [Exception Handling](../development/exception-handling.md)
---
## Version History
- **v1.0** (2025-12-13): Initial Letzshop order integration
- GraphQL client for order import
- Encrypted credential storage
- Fulfillment operations (confirm, reject, tracking)
- Admin and vendor API endpoints
- Sync logging and queue management

View File

@@ -186,6 +186,7 @@ nav:
- Shop Setup: guides/shop-setup.md
- CSV Import: guides/csv-import.md
- Marketplace Integration: guides/marketplace-integration.md
- Letzshop Order Integration: guides/letzshop-order-integration.md
# ============================================
# TROUBLESHOOTING

View File

@@ -28,6 +28,12 @@ from .marketplace_product import (
)
from .marketplace_product_translation import MarketplaceProductTranslation
from .order import Order, OrderItem
from .letzshop import (
LetzshopFulfillmentQueue,
LetzshopOrder,
LetzshopSyncLog,
VendorLetzshopCredentials,
)
from .product import Product
from .product_translation import ProductTranslation
from .user import User
@@ -82,4 +88,9 @@ __all__ = [
# Orders
"Order",
"OrderItem",
# Letzshop Integration
"VendorLetzshopCredentials",
"LetzshopOrder",
"LetzshopFulfillmentQueue",
"LetzshopSyncLog",
]

221
models/database/letzshop.py Normal file
View File

@@ -0,0 +1,221 @@
# models/database/letzshop.py
"""
Database models for Letzshop marketplace integration.
Provides models for:
- VendorLetzshopCredentials: Per-vendor API key storage (encrypted)
- LetzshopOrder: External order tracking and mapping
- LetzshopFulfillmentQueue: Outbound operation queue with retry
- LetzshopSyncLog: Audit trail for sync operations
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorLetzshopCredentials(Base, TimestampMixin):
"""
Per-vendor Letzshop API credentials.
Stores encrypted API keys and sync settings for each vendor's
Letzshop integration.
"""
__tablename__ = "vendor_letzshop_credentials"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
)
# Encrypted API credentials
api_key_encrypted = Column(Text, nullable=False)
api_endpoint = Column(String(255), default="https://letzshop.lu/graphql")
# Sync settings
auto_sync_enabled = Column(Boolean, default=False)
sync_interval_minutes = Column(Integer, default=15)
# Last sync status
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(50), nullable=True) # success, failed, partial
last_sync_error = Column(Text, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="letzshop_credentials")
def __repr__(self):
return f"<VendorLetzshopCredentials(vendor_id={self.vendor_id}, auto_sync={self.auto_sync_enabled})>"
class LetzshopOrder(Base, TimestampMixin):
"""
Letzshop order tracking and mapping.
Stores imported orders from Letzshop with mapping to local Order model.
"""
__tablename__ = "letzshop_orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
# Letzshop identifiers
letzshop_order_id = Column(String(100), nullable=False, index=True)
letzshop_shipment_id = Column(String(100), nullable=True, index=True)
letzshop_order_number = Column(String(100), nullable=True)
# Local order mapping (if imported to local system)
local_order_id = Column(Integer, ForeignKey("orders.id"), nullable=True)
# Order state from Letzshop
letzshop_state = Column(String(50), nullable=True) # unconfirmed, confirmed, etc.
# Customer info from Letzshop
customer_email = Column(String(255), nullable=True)
customer_name = Column(String(255), nullable=True)
# Order totals from Letzshop
total_amount = Column(String(50), nullable=True) # Store as string to preserve format
currency = Column(String(10), default="EUR")
# Raw data storage (for debugging/auditing)
raw_order_data = Column(JSON, nullable=True)
# Inventory units (from Letzshop)
inventory_units = Column(JSON, nullable=True) # List of inventory unit IDs
# Sync status
sync_status = Column(
String(50), default="pending"
) # pending, imported, confirmed, rejected, shipped
last_synced_at = Column(DateTime(timezone=True), nullable=True)
sync_error = Column(Text, nullable=True)
# Fulfillment status
confirmed_at = Column(DateTime(timezone=True), nullable=True)
rejected_at = Column(DateTime(timezone=True), nullable=True)
tracking_set_at = Column(DateTime(timezone=True), nullable=True)
tracking_number = Column(String(100), nullable=True)
tracking_carrier = Column(String(100), nullable=True)
# Relationships
vendor = relationship("Vendor")
local_order = relationship("Order")
__table_args__ = (
Index("idx_letzshop_order_vendor", "vendor_id", "letzshop_order_id"),
Index("idx_letzshop_order_state", "vendor_id", "letzshop_state"),
Index("idx_letzshop_order_sync", "vendor_id", "sync_status"),
)
def __repr__(self):
return f"<LetzshopOrder(id={self.id}, letzshop_id='{self.letzshop_order_id}', state='{self.letzshop_state}')>"
class LetzshopFulfillmentQueue(Base, TimestampMixin):
"""
Queue for outbound fulfillment operations to Letzshop.
Supports retry logic for failed operations.
"""
__tablename__ = "letzshop_fulfillment_queue"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
letzshop_order_id = Column(
Integer, ForeignKey("letzshop_orders.id"), nullable=False
)
# Operation type
operation = Column(
String(50), nullable=False
) # confirm, reject, set_tracking, return
# Operation payload
payload = Column(JSON, nullable=False)
# Status and retry
status = Column(
String(50), default="pending"
) # pending, processing, completed, failed
attempts = Column(Integer, default=0)
max_attempts = Column(Integer, default=3)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
next_retry_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Response from Letzshop
response_data = Column(JSON, nullable=True)
# Relationships
vendor = relationship("Vendor")
letzshop_order = relationship("LetzshopOrder")
__table_args__ = (
Index("idx_fulfillment_queue_status", "status", "vendor_id"),
Index("idx_fulfillment_queue_retry", "status", "next_retry_at"),
)
def __repr__(self):
return f"<LetzshopFulfillmentQueue(id={self.id}, operation='{self.operation}', status='{self.status}')>"
class LetzshopSyncLog(Base, TimestampMixin):
"""
Audit log for all Letzshop sync operations.
"""
__tablename__ = "letzshop_sync_logs"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
# Operation details
operation_type = Column(
String(50), nullable=False
) # order_import, confirm_inventory, set_tracking, etc.
direction = Column(String(10), nullable=False) # inbound, outbound
# Status
status = Column(String(50), nullable=False) # success, failed, partial
# Details
records_processed = Column(Integer, default=0)
records_succeeded = Column(Integer, default=0)
records_failed = Column(Integer, default=0)
error_details = Column(JSON, nullable=True)
# Timestamps
started_at = Column(DateTime(timezone=True), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
# Triggered by
triggered_by = Column(String(100), nullable=True) # user_id, scheduler, webhook
# Relationships
vendor = relationship("Vendor")
__table_args__ = (
Index("idx_sync_log_vendor_type", "vendor_id", "operation_type"),
Index("idx_sync_log_vendor_date", "vendor_id", "started_at"),
)
def __repr__(self):
return f"<LetzshopSyncLog(id={self.id}, type='{self.operation_type}', status='{self.status}')>"

View File

@@ -10,6 +10,7 @@ from sqlalchemy import (
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -29,6 +30,15 @@ class Order(Base, TimestampMixin):
order_number = Column(String, nullable=False, unique=True, index=True)
# Order channel/source
channel = Column(
String(50), default="direct", index=True
) # direct, letzshop, amazon, etc.
external_order_id = Column(
String(100), nullable=True, index=True
) # External order reference
external_channel_data = Column(JSON, nullable=True) # Channel-specific metadata
# Order status
status = Column(String, nullable=False, default="pending", index=True)
# pending, processing, shipped, delivered, cancelled, refunded

View File

@@ -96,6 +96,14 @@ class Vendor(Base, TimestampMixin):
"MarketplaceImportJob", back_populates="vendor"
) # Relationship with MarketplaceImportJob model for import jobs related to this vendor
# Letzshop integration credentials (one-to-one)
letzshop_credentials = relationship(
"VendorLetzshopCredentials",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
domains = relationship(
"VendorDomain",
back_populates="vendor",

337
models/schema/letzshop.py Normal file
View File

@@ -0,0 +1,337 @@
# models/schema/letzshop.py
"""
Pydantic schemas for Letzshop marketplace integration.
Covers:
- Vendor credentials management
- Letzshop order import/sync
- Fulfillment queue operations
- Sync logs
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ============================================================================
# Credentials Schemas
# ============================================================================
class LetzshopCredentialsCreate(BaseModel):
"""Schema for creating/updating Letzshop credentials."""
api_key: str = Field(..., min_length=1, description="Letzshop API key")
api_endpoint: str | None = Field(
None,
description="Custom API endpoint (defaults to https://letzshop.lu/graphql)",
)
auto_sync_enabled: bool = Field(
False, description="Enable automatic order sync"
)
sync_interval_minutes: int = Field(
15, ge=5, le=1440, description="Sync interval in minutes (5-1440)"
)
class LetzshopCredentialsUpdate(BaseModel):
"""Schema for updating Letzshop credentials (partial update)."""
api_key: str | None = Field(None, min_length=1)
api_endpoint: str | None = None
auto_sync_enabled: bool | None = None
sync_interval_minutes: int | None = Field(None, ge=5, le=1440)
class LetzshopCredentialsResponse(BaseModel):
"""Schema for Letzshop credentials response (API key is masked)."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
api_key_masked: str = Field(..., description="Masked API key for display")
api_endpoint: str
auto_sync_enabled: bool
sync_interval_minutes: int
last_sync_at: datetime | None
last_sync_status: str | None
last_sync_error: str | None
created_at: datetime
updated_at: datetime
class LetzshopCredentialsStatus(BaseModel):
"""Schema for Letzshop connection status."""
is_configured: bool
is_connected: bool
last_sync_at: datetime | None
last_sync_status: str | None
auto_sync_enabled: bool
# ============================================================================
# Letzshop Order Schemas
# ============================================================================
class LetzshopInventoryUnit(BaseModel):
"""Schema for Letzshop inventory unit."""
id: str
state: str
class LetzshopOrderBase(BaseModel):
"""Base schema for Letzshop order."""
letzshop_order_id: str
letzshop_shipment_id: str | None = None
letzshop_order_number: str | None = None
letzshop_state: str | None = None
customer_email: str | None = None
customer_name: str | None = None
total_amount: str | None = None
currency: str = "EUR"
class LetzshopOrderCreate(LetzshopOrderBase):
"""Schema for creating a Letzshop order record."""
vendor_id: int
raw_order_data: dict[str, Any] | None = None
inventory_units: list[dict[str, Any]] | None = None
class LetzshopOrderResponse(LetzshopOrderBase):
"""Schema for Letzshop order response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
local_order_id: int | None
sync_status: str
last_synced_at: datetime | None
sync_error: str | None
confirmed_at: datetime | None
rejected_at: datetime | None
tracking_set_at: datetime | None
tracking_number: str | None
tracking_carrier: str | None
inventory_units: list[dict[str, Any]] | None
created_at: datetime
updated_at: datetime
class LetzshopOrderDetailResponse(LetzshopOrderResponse):
"""Schema for detailed Letzshop order response with raw data."""
raw_order_data: dict[str, Any] | None = None
class LetzshopOrderListResponse(BaseModel):
"""Schema for paginated Letzshop order list."""
orders: list[LetzshopOrderResponse]
total: int
skip: int
limit: int
# ============================================================================
# Fulfillment Schemas
# ============================================================================
class FulfillmentConfirmRequest(BaseModel):
"""Schema for confirming order fulfillment."""
inventory_unit_ids: list[str] = Field(
..., min_length=1, description="List of inventory unit IDs to confirm"
)
class FulfillmentRejectRequest(BaseModel):
"""Schema for rejecting order fulfillment."""
inventory_unit_ids: list[str] = Field(
..., min_length=1, description="List of inventory unit IDs to reject"
)
reason: str | None = Field(None, max_length=500, description="Rejection reason")
class FulfillmentTrackingRequest(BaseModel):
"""Schema for setting tracking information."""
tracking_number: str = Field(..., min_length=1, max_length=100)
tracking_carrier: str = Field(
..., min_length=1, max_length=100, description="Carrier code (e.g., dhl, ups)"
)
class FulfillmentQueueItemResponse(BaseModel):
"""Schema for fulfillment queue item response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
letzshop_order_id: int
operation: str
payload: dict[str, Any]
status: str
attempts: int
max_attempts: int
last_attempt_at: datetime | None
next_retry_at: datetime | None
error_message: str | None
completed_at: datetime | None
response_data: dict[str, Any] | None
created_at: datetime
updated_at: datetime
class FulfillmentQueueListResponse(BaseModel):
"""Schema for paginated fulfillment queue list."""
items: list[FulfillmentQueueItemResponse]
total: int
skip: int
limit: int
# ============================================================================
# Sync Log Schemas
# ============================================================================
class LetzshopSyncLogResponse(BaseModel):
"""Schema for Letzshop sync log response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
operation_type: str
direction: str
status: str
records_processed: int
records_succeeded: int
records_failed: int
error_details: dict[str, Any] | None
started_at: datetime
completed_at: datetime | None
duration_seconds: int | None
triggered_by: str | None
created_at: datetime
class LetzshopSyncLogListResponse(BaseModel):
"""Schema for paginated sync log list."""
logs: list[LetzshopSyncLogResponse]
total: int
skip: int
limit: int
# ============================================================================
# Sync Trigger Schemas
# ============================================================================
class LetzshopSyncTriggerRequest(BaseModel):
"""Schema for triggering a sync operation."""
operation: str = Field(
"order_import",
pattern="^(order_import|full_sync)$",
description="Type of sync operation",
)
class LetzshopSyncTriggerResponse(BaseModel):
"""Schema for sync trigger response."""
success: bool
message: str
sync_log_id: int | None = None
orders_imported: int = 0
orders_updated: int = 0
errors: list[str] = Field(default_factory=list)
# ============================================================================
# Connection Test Schemas
# ============================================================================
class LetzshopConnectionTestRequest(BaseModel):
"""Schema for testing Letzshop connection."""
api_key: str = Field(..., min_length=1, description="API key to test")
api_endpoint: str | None = Field(None, description="Custom endpoint to test")
class LetzshopConnectionTestResponse(BaseModel):
"""Schema for connection test response."""
success: bool
message: str
response_time_ms: float | None = None
error_details: str | None = None
# ============================================================================
# Generic Response Schemas
# ============================================================================
class LetzshopSuccessResponse(BaseModel):
"""Generic success response for Letzshop operations."""
success: bool
message: str
class FulfillmentOperationResponse(BaseModel):
"""Response for fulfillment operations (confirm, reject, tracking)."""
success: bool
message: str
confirmed_units: list[str] | None = None
tracking_number: str | None = None
tracking_carrier: str | None = None
errors: list[str] | None = None
# ============================================================================
# Admin Overview Schemas
# ============================================================================
class LetzshopVendorOverview(BaseModel):
"""Schema for vendor Letzshop integration overview (admin view)."""
vendor_id: int
vendor_name: str
vendor_code: str
is_configured: bool
auto_sync_enabled: bool
last_sync_at: datetime | None
last_sync_status: str | None
pending_orders: int
total_orders: int
class LetzshopVendorListResponse(BaseModel):
"""Schema for paginated vendor Letzshop overview list."""
vendors: list[LetzshopVendorOverview]
total: int
skip: int
limit: int

View File

@@ -0,0 +1,361 @@
# tests/integration/api/v1/admin/test_letzshop.py
"""
Integration tests for admin Letzshop API endpoints.
Tests cover:
1. Vendor Letzshop status overview
2. Credentials management for vendors
3. Connection testing
4. Order management for vendors
"""
import pytest
from unittest.mock import patch, MagicMock
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopVendorsAPI:
"""Test admin Letzshop vendor overview endpoints."""
def test_list_vendors_letzshop_status(
self, client, admin_headers, test_vendor
):
"""Test listing vendors with Letzshop status."""
response = client.get(
"/api/v1/admin/letzshop/vendors", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert "vendors" in data
assert "total" in data
# Find our test vendor
vendor_found = False
for v in data["vendors"]:
if v["vendor_id"] == test_vendor.id:
vendor_found = True
assert v["is_configured"] is False # Not configured yet
break
# Vendor may not be found if inactive, that's ok
def test_list_vendors_configured_only(
self, client, db, admin_headers, test_vendor
):
"""Test listing only configured vendors."""
from models.database.letzshop import VendorLetzshopCredentials
from app.utils.encryption import encrypt_value
# Configure credentials for test vendor
credentials = VendorLetzshopCredentials(
vendor_id=test_vendor.id,
api_key_encrypted=encrypt_value("test-key"),
api_endpoint="https://letzshop.lu/graphql",
)
db.add(credentials)
db.commit()
response = client.get(
"/api/v1/admin/letzshop/vendors?configured_only=true",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
# All returned vendors should be configured
for v in data["vendors"]:
assert v["is_configured"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopCredentialsAPI:
"""Test admin Letzshop credentials management endpoints."""
def test_get_vendor_credentials_not_configured(
self, client, admin_headers, test_vendor
):
"""Test getting credentials when not configured returns 404."""
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
)
assert response.status_code == 404
def test_create_vendor_credentials(
self, client, admin_headers, test_vendor
):
"""Test creating credentials for a vendor."""
response = client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={
"api_key": "admin-set-api-key-12345",
"auto_sync_enabled": True,
"sync_interval_minutes": 60,
},
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor.id
assert "****" in data["api_key_masked"]
assert data["auto_sync_enabled"] is True
assert data["sync_interval_minutes"] == 60
def test_get_vendor_credentials_after_create(
self, client, db, admin_headers, test_vendor
):
"""Test getting credentials after creation."""
# Create first
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "test-key"},
)
# Get
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor.id
def test_update_vendor_credentials(
self, client, admin_headers, test_vendor
):
"""Test partial update of vendor credentials."""
# Create first
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "original-key", "auto_sync_enabled": False},
)
# Update
response = client.patch(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"auto_sync_enabled": True},
)
assert response.status_code == 200
data = response.json()
assert data["auto_sync_enabled"] is True
def test_delete_vendor_credentials(
self, client, admin_headers, test_vendor
):
"""Test deleting vendor credentials."""
# Create first
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "test-key"},
)
# Delete
response = client.delete(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
def test_vendor_not_found(self, client, admin_headers):
"""Test operations on non-existent vendor return 404."""
response = client.get(
"/api/v1/admin/letzshop/vendors/99999/credentials",
headers=admin_headers,
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopConnectionAPI:
"""Test admin Letzshop connection testing endpoints."""
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_vendor_connection(
self, mock_post, client, admin_headers, test_vendor
):
"""Test connection for a specific vendor."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
# Create credentials
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "test-key"},
)
# Test connection
response = client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/test",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_api_key_directly(
self, mock_post, client, admin_headers
):
"""Test any API key without associating with vendor."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
response = client.post(
"/api/v1/admin/letzshop/test",
headers=admin_headers,
json={"api_key": "test-api-key-to-validate"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopOrdersAPI:
"""Test admin Letzshop order management endpoints."""
def test_list_vendor_orders_empty(
self, client, admin_headers, test_vendor
):
"""Test listing vendor orders when none exist."""
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/orders",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["orders"] == []
assert data["total"] == 0
def test_list_vendor_orders_with_data(
self, client, db, admin_headers, test_vendor
):
"""Test listing vendor orders with data."""
from models.database.letzshop import LetzshopOrder
# Create test orders
order = LetzshopOrder(
vendor_id=test_vendor.id,
letzshop_order_id="admin_order_1",
letzshop_state="unconfirmed",
customer_email="admin-test@example.com",
total_amount="150.00",
sync_status="pending",
)
db.add(order)
db.commit()
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/orders",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["orders"][0]["customer_email"] == "admin-test@example.com"
@patch("app.services.letzshop.client.requests.Session.post")
def test_trigger_vendor_sync(
self, mock_post, client, admin_headers, test_vendor
):
"""Test triggering sync for a vendor."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"nodes": [
{
"id": "gid://letzshop/Shipment/789",
"state": "unconfirmed",
"order": {
"id": "gid://letzshop/Order/111",
"number": "LS-ADMIN-001",
"email": "sync@example.com",
"totalPrice": {"amount": "200.00", "currency": "EUR"},
},
"inventoryUnits": {"nodes": []},
}
]
}
}
}
mock_post.return_value = mock_response
# Create credentials
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "admin-sync-key"},
)
# Trigger sync
response = client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/sync",
headers=admin_headers,
json={"operation": "order_import"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["orders_imported"] >= 0
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopAccessControl:
"""Test admin access control for Letzshop endpoints."""
def test_non_admin_cannot_access(
self, client, auth_headers, test_vendor
):
"""Test that non-admin users cannot access admin endpoints."""
response = client.get(
"/api/v1/admin/letzshop/vendors",
headers=auth_headers,
)
assert response.status_code == 403
def test_unauthenticated_cannot_access(self, client):
"""Test that unauthenticated requests are rejected."""
response = client.get("/api/v1/admin/letzshop/vendors")
assert response.status_code == 401

View File

@@ -0,0 +1,499 @@
# tests/integration/api/v1/vendor/test_letzshop.py
"""
Integration tests for vendor Letzshop API endpoints.
Tests cover:
1. Credentials management (CRUD)
2. Connection testing
3. Order listing
"""
import pytest
from unittest.mock import patch, MagicMock
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopCredentialsAPI:
"""Test vendor Letzshop credentials endpoints."""
def test_get_status_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting status when not configured."""
response = client.get(
"/api/v1/vendor/letzshop/status", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["is_configured"] is False
assert data["auto_sync_enabled"] is False
def test_save_credentials(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test saving Letzshop credentials."""
response = client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={
"api_key": "test-letzshop-api-key-12345",
"auto_sync_enabled": False,
"sync_interval_minutes": 30,
},
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor_with_vendor_user.id
assert "****" in data["api_key_masked"]
assert data["auto_sync_enabled"] is False
assert data["sync_interval_minutes"] == 30
def test_get_credentials_after_save(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting credentials after saving."""
# Save first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "my-test-key"},
)
# Get
response = client.get(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor_with_vendor_user.id
assert "api_key_masked" in data
def test_get_credentials_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting credentials when not configured returns 404."""
response = client.get(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 404
def test_update_credentials(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test partial update of credentials."""
# Save first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "original-key", "auto_sync_enabled": False},
)
# Update
response = client.patch(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"auto_sync_enabled": True, "sync_interval_minutes": 60},
)
assert response.status_code == 200
data = response.json()
assert data["auto_sync_enabled"] is True
assert data["sync_interval_minutes"] == 60
def test_delete_credentials(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test deleting credentials."""
# Save first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Delete
response = client.delete(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify deleted
response = client.get(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 404
def test_delete_credentials_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test deleting when not configured returns 404."""
response = client.delete(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopConnectionAPI:
"""Test vendor Letzshop connection testing endpoints."""
def test_test_connection_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test connection when not configured."""
response = client.post(
"/api/v1/vendor/letzshop/test", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert "not configured" in data["error_details"]
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_connection_success(
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test successful connection test."""
# Mock successful response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
# Save credentials first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "valid-test-key"},
)
# Test connection
response = client.post(
"/api/v1/vendor/letzshop/test", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["response_time_ms"] is not None
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_api_key_without_saving(
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test API key without saving it."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
response = client.post(
"/api/v1/vendor/letzshop/test-key",
headers=vendor_user_headers,
json={"api_key": "test-key-to-validate"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopOrdersAPI:
"""Test vendor Letzshop order endpoints."""
def test_list_orders_empty(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test listing orders when none exist."""
response = client.get(
"/api/v1/vendor/letzshop/orders", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["orders"] == []
assert data["total"] == 0
def test_list_orders_with_filters(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test listing orders with status filter."""
from models.database.letzshop import LetzshopOrder
# Create test orders
order1 = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_1",
letzshop_state="unconfirmed",
sync_status="pending",
)
order2 = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_2",
letzshop_state="confirmed",
sync_status="confirmed",
)
db.add_all([order1, order2])
db.commit()
# List pending only
response = client.get(
"/api/v1/vendor/letzshop/orders?sync_status=pending",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["orders"][0]["sync_status"] == "pending"
def test_get_order_detail(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting order detail."""
from models.database.letzshop import LetzshopOrder
order = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_detail_test",
letzshop_shipment_id="shipment_1",
letzshop_state="unconfirmed",
customer_email="test@example.com",
total_amount="99.99",
sync_status="pending",
raw_order_data={"test": "data"},
)
db.add(order)
db.commit()
response = client.get(
f"/api/v1/vendor/letzshop/orders/{order.id}",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["letzshop_order_id"] == "order_detail_test"
assert data["customer_email"] == "test@example.com"
assert data["raw_order_data"] == {"test": "data"}
def test_get_order_not_found(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting non-existent order returns 404."""
response = client.get(
"/api/v1/vendor/letzshop/orders/99999",
headers=vendor_user_headers,
)
assert response.status_code == 404
def test_import_orders_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test importing orders when not configured."""
response = client.post(
"/api/v1/vendor/letzshop/orders/import",
headers=vendor_user_headers,
json={},
)
assert response.status_code == 422 # Validation error
@patch("app.services.letzshop.client.requests.Session.post")
def test_import_orders_success(
self,
mock_post,
client,
db,
vendor_user_headers,
test_vendor_with_vendor_user,
):
"""Test successful order import."""
# Mock Letzshop API response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"nodes": [
{
"id": "gid://letzshop/Shipment/123",
"state": "unconfirmed",
"order": {
"id": "gid://letzshop/Order/456",
"number": "LS-2025-001",
"email": "customer@example.com",
"totalPrice": {"amount": "99.99", "currency": "EUR"},
},
"inventoryUnits": {
"nodes": [
{"id": "unit_1", "state": "unconfirmed"},
]
},
}
]
}
}
}
mock_post.return_value = mock_response
# Save credentials first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Import orders
response = client.post(
"/api/v1/vendor/letzshop/orders/import",
headers=vendor_user_headers,
json={"operation": "order_import"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["orders_imported"] == 1
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopFulfillmentAPI:
"""Test vendor Letzshop fulfillment endpoints."""
@patch("app.services.letzshop.client.requests.Session.post")
def test_confirm_order(
self,
mock_post,
client,
db,
vendor_user_headers,
test_vendor_with_vendor_user,
):
"""Test confirming an order."""
from models.database.letzshop import LetzshopOrder
# Create test order
order = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_confirm",
letzshop_shipment_id="shipment_1",
letzshop_state="unconfirmed",
sync_status="pending",
inventory_units=[{"id": "unit_1", "state": "unconfirmed"}],
)
db.add(order)
db.commit()
# Save credentials
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Mock confirm response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"confirmInventoryUnits": {
"inventoryUnits": [{"id": "unit_1", "state": "confirmed"}],
"errors": [],
}
}
}
mock_post.return_value = mock_response
# Confirm order
response = client.post(
f"/api/v1/vendor/letzshop/orders/{order.id}/confirm",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@patch("app.services.letzshop.client.requests.Session.post")
def test_set_tracking(
self,
mock_post,
client,
db,
vendor_user_headers,
test_vendor_with_vendor_user,
):
"""Test setting tracking information."""
from models.database.letzshop import LetzshopOrder
order = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_tracking",
letzshop_shipment_id="shipment_track",
letzshop_state="confirmed",
sync_status="confirmed",
)
db.add(order)
db.commit()
# Save credentials
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Mock tracking response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"setShipmentTracking": {
"shipment": {
"id": "shipment_track",
"tracking": {"code": "1Z999AA1", "provider": "ups"},
},
"errors": [],
}
}
}
mock_post.return_value = mock_response
# Set tracking
response = client.post(
f"/api/v1/vendor/letzshop/orders/{order.id}/tracking",
headers=vendor_user_headers,
json={
"tracking_number": "1Z999AA1",
"tracking_carrier": "ups",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["tracking_number"] == "1Z999AA1"

View File

@@ -0,0 +1,460 @@
# tests/unit/services/test_letzshop_service.py
"""
Unit tests for Letzshop integration services.
Tests cover:
- Encryption utility
- Credentials service
- GraphQL client (mocked)
"""
import pytest
from unittest.mock import MagicMock, patch
from app.services.letzshop import (
LetzshopClient,
LetzshopClientError,
LetzshopAuthError,
LetzshopAPIError,
LetzshopCredentialsService,
CredentialsNotFoundError,
)
from app.utils.encryption import (
EncryptionService,
EncryptionError,
encrypt_value,
decrypt_value,
mask_api_key,
)
# ============================================================================
# Encryption Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.letzshop
class TestEncryptionService:
"""Test suite for encryption utility."""
def test_encrypt_and_decrypt(self):
"""Test basic encryption and decryption."""
service = EncryptionService(secret_key="test-secret-key-12345")
original = "my-secret-api-key"
encrypted = service.encrypt(original)
decrypted = service.decrypt(encrypted)
assert encrypted != original
assert decrypted == original
def test_encrypt_empty_string_fails(self):
"""Test that encrypting empty string raises error."""
service = EncryptionService(secret_key="test-secret-key-12345")
with pytest.raises(EncryptionError):
service.encrypt("")
def test_decrypt_empty_string_fails(self):
"""Test that decrypting empty string raises error."""
service = EncryptionService(secret_key="test-secret-key-12345")
with pytest.raises(EncryptionError):
service.decrypt("")
def test_decrypt_invalid_ciphertext_fails(self):
"""Test that decrypting invalid ciphertext raises error."""
service = EncryptionService(secret_key="test-secret-key-12345")
with pytest.raises(EncryptionError):
service.decrypt("invalid-ciphertext")
def test_is_valid_ciphertext(self):
"""Test ciphertext validation."""
service = EncryptionService(secret_key="test-secret-key-12345")
encrypted = service.encrypt("test-value")
assert service.is_valid_ciphertext(encrypted) is True
assert service.is_valid_ciphertext("invalid") is False
def test_different_keys_produce_different_results(self):
"""Test that different keys produce different encryptions."""
service1 = EncryptionService(secret_key="key-one-12345")
service2 = EncryptionService(secret_key="key-two-12345")
original = "test-value"
encrypted1 = service1.encrypt(original)
encrypted2 = service2.encrypt(original)
assert encrypted1 != encrypted2
@pytest.mark.unit
@pytest.mark.letzshop
class TestMaskApiKey:
"""Test suite for API key masking."""
def test_mask_api_key_default(self):
"""Test default masking (4 visible chars)."""
masked = mask_api_key("letzshop-api-key-12345")
assert masked == "letz******************"
def test_mask_api_key_custom_visible(self):
"""Test masking with custom visible chars."""
masked = mask_api_key("abcdefghij", visible_chars=6)
assert masked == "abcdef****"
def test_mask_api_key_short(self):
"""Test masking short key."""
masked = mask_api_key("abc", visible_chars=4)
assert masked == "***"
def test_mask_api_key_empty(self):
"""Test masking empty string."""
masked = mask_api_key("")
assert masked == ""
# ============================================================================
# Credentials Service Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.letzshop
class TestLetzshopCredentialsService:
"""Test suite for Letzshop credentials service."""
def test_create_credentials(self, db, test_vendor):
"""Test creating credentials for a vendor."""
service = LetzshopCredentialsService(db)
credentials = service.create_credentials(
vendor_id=test_vendor.id,
api_key="test-api-key-12345",
auto_sync_enabled=False,
sync_interval_minutes=30,
)
assert credentials.vendor_id == test_vendor.id
assert credentials.api_key_encrypted != "test-api-key-12345"
assert credentials.auto_sync_enabled is False
assert credentials.sync_interval_minutes == 30
def test_get_credentials(self, db, test_vendor):
"""Test getting credentials for a vendor."""
service = LetzshopCredentialsService(db)
# Create first
service.create_credentials(
vendor_id=test_vendor.id,
api_key="test-api-key",
)
# Get
credentials = service.get_credentials(test_vendor.id)
assert credentials is not None
assert credentials.vendor_id == test_vendor.id
def test_get_credentials_not_found(self, db, test_vendor):
"""Test getting non-existent credentials returns None."""
service = LetzshopCredentialsService(db)
credentials = service.get_credentials(test_vendor.id)
assert credentials is None
def test_get_credentials_or_raise(self, db, test_vendor):
"""Test get_credentials_or_raise raises for non-existent."""
service = LetzshopCredentialsService(db)
with pytest.raises(CredentialsNotFoundError):
service.get_credentials_or_raise(test_vendor.id)
def test_update_credentials(self, db, test_vendor):
"""Test updating credentials."""
service = LetzshopCredentialsService(db)
# Create first
service.create_credentials(
vendor_id=test_vendor.id,
api_key="original-key",
auto_sync_enabled=False,
)
# Update
updated = service.update_credentials(
vendor_id=test_vendor.id,
auto_sync_enabled=True,
sync_interval_minutes=60,
)
assert updated.auto_sync_enabled is True
assert updated.sync_interval_minutes == 60
def test_delete_credentials(self, db, test_vendor):
"""Test deleting credentials."""
service = LetzshopCredentialsService(db)
# Create first
service.create_credentials(
vendor_id=test_vendor.id,
api_key="test-key",
)
# Delete
result = service.delete_credentials(test_vendor.id)
assert result is True
# Verify deleted
assert service.get_credentials(test_vendor.id) is None
def test_delete_credentials_not_found(self, db, test_vendor):
"""Test deleting non-existent credentials returns False."""
service = LetzshopCredentialsService(db)
result = service.delete_credentials(test_vendor.id)
assert result is False
def test_upsert_credentials_create(self, db, test_vendor):
"""Test upsert creates when not exists."""
service = LetzshopCredentialsService(db)
credentials = service.upsert_credentials(
vendor_id=test_vendor.id,
api_key="new-key",
)
assert credentials.vendor_id == test_vendor.id
def test_upsert_credentials_update(self, db, test_vendor):
"""Test upsert updates when exists."""
service = LetzshopCredentialsService(db)
# Create first
service.create_credentials(
vendor_id=test_vendor.id,
api_key="original-key",
auto_sync_enabled=False,
)
# Upsert with new values
credentials = service.upsert_credentials(
vendor_id=test_vendor.id,
api_key="updated-key",
auto_sync_enabled=True,
)
assert credentials.auto_sync_enabled is True
def test_get_decrypted_api_key(self, db, test_vendor):
"""Test getting decrypted API key."""
service = LetzshopCredentialsService(db)
original_key = "my-secret-api-key"
service.create_credentials(
vendor_id=test_vendor.id,
api_key=original_key,
)
decrypted = service.get_decrypted_api_key(test_vendor.id)
assert decrypted == original_key
def test_get_masked_api_key(self, db, test_vendor):
"""Test getting masked API key."""
service = LetzshopCredentialsService(db)
service.create_credentials(
vendor_id=test_vendor.id,
api_key="letzshop-api-key-12345",
)
masked = service.get_masked_api_key(test_vendor.id)
assert masked.startswith("letz")
assert "*" in masked
def test_is_configured(self, db, test_vendor):
"""Test is_configured check."""
service = LetzshopCredentialsService(db)
assert service.is_configured(test_vendor.id) is False
service.create_credentials(
vendor_id=test_vendor.id,
api_key="test-key",
)
assert service.is_configured(test_vendor.id) is True
def test_get_status(self, db, test_vendor):
"""Test getting integration status."""
service = LetzshopCredentialsService(db)
# Not configured
status = service.get_status(test_vendor.id)
assert status["is_configured"] is False
assert status["auto_sync_enabled"] is False
# Configured
service.create_credentials(
vendor_id=test_vendor.id,
api_key="test-key",
auto_sync_enabled=True,
)
status = service.get_status(test_vendor.id)
assert status["is_configured"] is True
assert status["auto_sync_enabled"] is True
# ============================================================================
# GraphQL Client Tests (Mocked)
# ============================================================================
@pytest.mark.unit
@pytest.mark.letzshop
class TestLetzshopClient:
"""Test suite for Letzshop GraphQL client (mocked)."""
def test_client_initialization(self):
"""Test client initialization."""
client = LetzshopClient(
api_key="test-key",
endpoint="https://test.example.com/graphql",
timeout=60,
)
assert client.api_key == "test-key"
assert client.endpoint == "https://test.example.com/graphql"
assert client.timeout == 60
def test_client_context_manager(self):
"""Test client can be used as context manager."""
with LetzshopClient(api_key="test-key") as client:
assert client is not None
@patch("requests.Session.post")
def test_test_connection_success(self, mock_post):
"""Test successful connection test."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {"__typename": "Query"}
}
mock_post.return_value = mock_response
client = LetzshopClient(api_key="test-key")
success, response_time, error = client.test_connection()
assert success is True
assert response_time > 0
assert error is None
@patch("requests.Session.post")
def test_test_connection_auth_failure(self, mock_post):
"""Test connection test with auth failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_post.return_value = mock_response
client = LetzshopClient(api_key="invalid-key")
success, response_time, error = client.test_connection()
assert success is False
assert "Authentication" in error
@patch("requests.Session.post")
def test_get_shipments(self, mock_post):
"""Test getting shipments."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"nodes": [
{"id": "ship_1", "state": "unconfirmed"},
{"id": "ship_2", "state": "unconfirmed"},
]
}
}
}
mock_post.return_value = mock_response
client = LetzshopClient(api_key="test-key")
shipments = client.get_shipments(state="unconfirmed")
assert len(shipments) == 2
assert shipments[0]["id"] == "ship_1"
@patch("requests.Session.post")
def test_confirm_inventory_units(self, mock_post):
"""Test confirming inventory units."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"confirmInventoryUnits": {
"inventoryUnits": [
{"id": "unit_1", "state": "confirmed"},
],
"errors": []
}
}
}
mock_post.return_value = mock_response
client = LetzshopClient(api_key="test-key")
result = client.confirm_inventory_units(["unit_1"])
assert result["inventoryUnits"][0]["state"] == "confirmed"
assert len(result["errors"]) == 0
@patch("requests.Session.post")
def test_set_shipment_tracking(self, mock_post):
"""Test setting shipment tracking."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"setShipmentTracking": {
"shipment": {
"id": "ship_1",
"tracking": {
"code": "1Z999AA1",
"provider": "ups"
}
},
"errors": []
}
}
}
mock_post.return_value = mock_response
client = LetzshopClient(api_key="test-key")
result = client.set_shipment_tracking(
shipment_id="ship_1",
tracking_code="1Z999AA1",
tracking_provider="ups",
)
assert result["shipment"]["tracking"]["code"] == "1Z999AA1"
@patch("requests.Session.post")
def test_graphql_error_handling(self, mock_post):
"""Test GraphQL error response handling."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"errors": [
{"message": "Invalid shipment ID"}
]
}
mock_post.return_value = mock_response
client = LetzshopClient(api_key="test-key")
with pytest.raises(LetzshopAPIError) as exc_info:
client.get_shipments()
assert "Invalid shipment ID" in str(exc_info.value)