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:
179
alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py
Normal file
179
alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py
Normal 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')
|
||||
@@ -32,6 +32,7 @@ from . import (
|
||||
companies,
|
||||
content_pages,
|
||||
dashboard,
|
||||
letzshop,
|
||||
logs,
|
||||
marketplace,
|
||||
monitoring,
|
||||
@@ -114,6 +115,9 @@ router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
||||
# Include marketplace monitoring endpoints
|
||||
router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||
|
||||
# Include Letzshop integration endpoints
|
||||
router.include_router(letzshop.router, tags=["admin-letzshop"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Administration
|
||||
|
||||
470
app/api/v1/admin/letzshop.py
Normal file
470
app/api/v1/admin/letzshop.py
Normal file
@@ -0,0 +1,470 @@
|
||||
# app/api/v1/admin/letzshop.py
|
||||
"""
|
||||
Admin API endpoints for Letzshop marketplace integration.
|
||||
|
||||
Provides admin-level management of:
|
||||
- Per-vendor Letzshop credentials
|
||||
- Connection testing
|
||||
- Sync triggers and status
|
||||
- Order overview
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ResourceNotFoundException, ValidationException
|
||||
from app.services.letzshop import (
|
||||
CredentialsNotFoundError,
|
||||
LetzshopClientError,
|
||||
LetzshopCredentialsService,
|
||||
LetzshopOrderService,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.letzshop import (
|
||||
LetzshopConnectionTestRequest,
|
||||
LetzshopConnectionTestResponse,
|
||||
LetzshopCredentialsCreate,
|
||||
LetzshopCredentialsResponse,
|
||||
LetzshopCredentialsUpdate,
|
||||
LetzshopOrderListResponse,
|
||||
LetzshopOrderResponse,
|
||||
LetzshopSuccessResponse,
|
||||
LetzshopSyncTriggerRequest,
|
||||
LetzshopSyncTriggerResponse,
|
||||
LetzshopVendorListResponse,
|
||||
LetzshopVendorOverview,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/letzshop")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_order_service(db: Session) -> LetzshopOrderService:
|
||||
"""Get order service instance."""
|
||||
return LetzshopOrderService(db)
|
||||
|
||||
|
||||
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
|
||||
"""Get credentials service instance."""
|
||||
return LetzshopCredentialsService(db)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Vendor Overview
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/vendors", response_model=LetzshopVendorListResponse)
|
||||
def list_vendors_letzshop_status(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
configured_only: bool = Query(False, description="Only show vendors with Letzshop configured"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all vendors with their Letzshop integration status.
|
||||
|
||||
Shows which vendors have Letzshop configured, sync status, and pending orders.
|
||||
"""
|
||||
order_service = get_order_service(db)
|
||||
vendor_overviews, total = order_service.list_vendors_with_letzshop_status(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
configured_only=configured_only,
|
||||
)
|
||||
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[LetzshopVendorOverview(**v) for v in vendor_overviews],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Credentials Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopCredentialsResponse,
|
||||
)
|
||||
def get_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get Letzshop credentials for a vendor (API key is masked)."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
try:
|
||||
credentials = creds_service.get_credentials_or_raise(vendor_id)
|
||||
except CredentialsNotFoundError:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(vendor_id),
|
||||
message=f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopCredentialsResponse,
|
||||
)
|
||||
def create_or_update_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
credentials_data: LetzshopCredentialsCreate = ...,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create or update Letzshop credentials for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
credentials = creds_service.upsert_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=credentials_data.api_key,
|
||||
api_endpoint=credentials_data.api_endpoint,
|
||||
auto_sync_enabled=credentials_data.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials_data.sync_interval_minutes,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.email} updated Letzshop credentials for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopCredentialsResponse,
|
||||
)
|
||||
def update_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
credentials_data: LetzshopCredentialsUpdate = ...,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Partially update Letzshop credentials for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
try:
|
||||
credentials = creds_service.update_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=credentials_data.api_key,
|
||||
api_endpoint=credentials_data.api_endpoint,
|
||||
auto_sync_enabled=credentials_data.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials_data.sync_interval_minutes,
|
||||
)
|
||||
except CredentialsNotFoundError:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(vendor_id),
|
||||
message=f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopSuccessResponse,
|
||||
)
|
||||
def delete_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete Letzshop credentials for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
deleted = creds_service.delete_credentials(vendor_id)
|
||||
if not deleted:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(vendor_id),
|
||||
message=f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.email} deleted Letzshop credentials for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Connection Testing
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vendors/{vendor_id}/test",
|
||||
response_model=LetzshopConnectionTestResponse,
|
||||
)
|
||||
def test_vendor_connection(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Test the Letzshop connection for a vendor using stored credentials."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
success, response_time_ms, error = creds_service.test_connection(vendor_id)
|
||||
|
||||
return LetzshopConnectionTestResponse(
|
||||
success=success,
|
||||
message="Connection successful" if success else "Connection failed",
|
||||
response_time_ms=response_time_ms,
|
||||
error_details=error,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=LetzshopConnectionTestResponse)
|
||||
def test_api_key(
|
||||
test_request: LetzshopConnectionTestRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Test a Letzshop API key without saving it."""
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
success, response_time_ms, error = creds_service.test_api_key(
|
||||
api_key=test_request.api_key,
|
||||
api_endpoint=test_request.api_endpoint,
|
||||
)
|
||||
|
||||
return LetzshopConnectionTestResponse(
|
||||
success=success,
|
||||
message="Connection successful" if success else "Connection failed",
|
||||
response_time_ms=response_time_ms,
|
||||
error_details=error,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_id}/orders",
|
||||
response_model=LetzshopOrderListResponse,
|
||||
)
|
||||
def list_vendor_letzshop_orders(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
sync_status: str | None = Query(None, description="Filter by sync status"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List Letzshop orders for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
|
||||
try:
|
||||
order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
orders, total = order_service.list_orders(
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
sync_status=sync_status,
|
||||
)
|
||||
|
||||
return LetzshopOrderListResponse(
|
||||
orders=[
|
||||
LetzshopOrderResponse(
|
||||
id=order.id,
|
||||
vendor_id=order.vendor_id,
|
||||
letzshop_order_id=order.letzshop_order_id,
|
||||
letzshop_shipment_id=order.letzshop_shipment_id,
|
||||
letzshop_order_number=order.letzshop_order_number,
|
||||
letzshop_state=order.letzshop_state,
|
||||
customer_email=order.customer_email,
|
||||
customer_name=order.customer_name,
|
||||
total_amount=order.total_amount,
|
||||
currency=order.currency,
|
||||
local_order_id=order.local_order_id,
|
||||
sync_status=order.sync_status,
|
||||
last_synced_at=order.last_synced_at,
|
||||
sync_error=order.sync_error,
|
||||
confirmed_at=order.confirmed_at,
|
||||
rejected_at=order.rejected_at,
|
||||
tracking_set_at=order.tracking_set_at,
|
||||
tracking_number=order.tracking_number,
|
||||
tracking_carrier=order.tracking_carrier,
|
||||
inventory_units=order.inventory_units,
|
||||
created_at=order.created_at,
|
||||
updated_at=order.updated_at,
|
||||
)
|
||||
for order in orders
|
||||
],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vendors/{vendor_id}/sync",
|
||||
response_model=LetzshopSyncTriggerResponse,
|
||||
)
|
||||
def trigger_vendor_sync(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Trigger a sync operation for a vendor.
|
||||
|
||||
This imports new orders from Letzshop.
|
||||
"""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
# Verify credentials exist
|
||||
try:
|
||||
creds_service.get_credentials_or_raise(vendor_id)
|
||||
except CredentialsNotFoundError:
|
||||
raise ValidationException(
|
||||
f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
# Import orders using the client
|
||||
try:
|
||||
with creds_service.create_client(vendor_id) as client:
|
||||
shipments = client.get_unconfirmed_shipments()
|
||||
|
||||
orders_imported = 0
|
||||
orders_updated = 0
|
||||
errors = []
|
||||
|
||||
for shipment in shipments:
|
||||
try:
|
||||
# Check if order already exists
|
||||
existing = order_service.get_order_by_shipment_id(
|
||||
vendor_id, shipment["id"]
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing order
|
||||
order_service.update_order_from_shipment(existing, shipment)
|
||||
orders_updated += 1
|
||||
else:
|
||||
# Create new order
|
||||
order_service.create_order(vendor_id, shipment)
|
||||
orders_imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
|
||||
|
||||
db.commit()
|
||||
|
||||
# Update sync status
|
||||
creds_service.update_sync_status(
|
||||
vendor_id,
|
||||
"success" if not errors else "partial",
|
||||
"; ".join(errors) if errors else None,
|
||||
)
|
||||
|
||||
return LetzshopSyncTriggerResponse(
|
||||
success=True,
|
||||
message=f"Sync completed: {orders_imported} imported, {orders_updated} updated",
|
||||
orders_imported=orders_imported,
|
||||
orders_updated=orders_updated,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
except LetzshopClientError as e:
|
||||
creds_service.update_sync_status(vendor_id, "failed", str(e))
|
||||
return LetzshopSyncTriggerResponse(
|
||||
success=False,
|
||||
message=f"Sync failed: {e}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -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
689
app/api/v1/vendor/letzshop.py
vendored
Normal 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,
|
||||
)
|
||||
45
app/services/letzshop/__init__.py
Normal file
45
app/services/letzshop/__init__.py
Normal 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",
|
||||
]
|
||||
493
app/services/letzshop/client.py
Normal file
493
app/services/letzshop/client.py
Normal 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", {})
|
||||
413
app/services/letzshop/credentials.py
Normal file
413
app/services/letzshop/credentials.py
Normal 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,
|
||||
}
|
||||
319
app/services/letzshop/order_service.py
Normal file
319
app/services/letzshop/order_service.py
Normal 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
187
app/utils/encryption.py
Normal 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)
|
||||
542
docs/guides/letzshop-order-integration.md
Normal file
542
docs/guides/letzshop-order-integration.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
221
models/database/letzshop.py
Normal 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}')>"
|
||||
@@ -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
|
||||
|
||||
@@ -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
337
models/schema/letzshop.py
Normal 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
|
||||
361
tests/integration/api/v1/admin/test_letzshop.py
Normal file
361
tests/integration/api/v1/admin/test_letzshop.py
Normal 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
|
||||
499
tests/integration/api/v1/vendor/test_letzshop.py
vendored
Normal file
499
tests/integration/api/v1/vendor/test_letzshop.py
vendored
Normal 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"
|
||||
460
tests/unit/services/test_letzshop_service.py
Normal file
460
tests/unit/services/test_letzshop_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user