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,
|
companies,
|
||||||
content_pages,
|
content_pages,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
letzshop,
|
||||||
logs,
|
logs,
|
||||||
marketplace,
|
marketplace,
|
||||||
monitoring,
|
monitoring,
|
||||||
@@ -114,6 +115,9 @@ router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
|||||||
# Include marketplace monitoring endpoints
|
# Include marketplace monitoring endpoints
|
||||||
router.include_router(marketplace.router, tags=["admin-marketplace"])
|
router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||||
|
|
||||||
|
# Include Letzshop integration endpoints
|
||||||
|
router.include_router(letzshop.router, tags=["admin-letzshop"])
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Platform Administration
|
# 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,
|
dashboard,
|
||||||
info,
|
info,
|
||||||
inventory,
|
inventory,
|
||||||
|
letzshop,
|
||||||
marketplace,
|
marketplace,
|
||||||
media,
|
media,
|
||||||
notifications,
|
notifications,
|
||||||
@@ -59,6 +60,7 @@ router.include_router(customers.router, tags=["vendor-customers"])
|
|||||||
router.include_router(team.router, tags=["vendor-team"])
|
router.include_router(team.router, tags=["vendor-team"])
|
||||||
router.include_router(inventory.router, tags=["vendor-inventory"])
|
router.include_router(inventory.router, tags=["vendor-inventory"])
|
||||||
router.include_router(marketplace.router, tags=["vendor-marketplace"])
|
router.include_router(marketplace.router, tags=["vendor-marketplace"])
|
||||||
|
router.include_router(letzshop.router, tags=["vendor-letzshop"])
|
||||||
|
|
||||||
# Services (with prefixes: /payments/*, /media/*, etc.)
|
# Services (with prefixes: /payments/*, /media/*, etc.)
|
||||||
router.include_router(payments.router, tags=["vendor-payments"])
|
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
|
- Shop Setup: guides/shop-setup.md
|
||||||
- CSV Import: guides/csv-import.md
|
- CSV Import: guides/csv-import.md
|
||||||
- Marketplace Integration: guides/marketplace-integration.md
|
- Marketplace Integration: guides/marketplace-integration.md
|
||||||
|
- Letzshop Order Integration: guides/letzshop-order-integration.md
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# TROUBLESHOOTING
|
# TROUBLESHOOTING
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ from .marketplace_product import (
|
|||||||
)
|
)
|
||||||
from .marketplace_product_translation import MarketplaceProductTranslation
|
from .marketplace_product_translation import MarketplaceProductTranslation
|
||||||
from .order import Order, OrderItem
|
from .order import Order, OrderItem
|
||||||
|
from .letzshop import (
|
||||||
|
LetzshopFulfillmentQueue,
|
||||||
|
LetzshopOrder,
|
||||||
|
LetzshopSyncLog,
|
||||||
|
VendorLetzshopCredentials,
|
||||||
|
)
|
||||||
from .product import Product
|
from .product import Product
|
||||||
from .product_translation import ProductTranslation
|
from .product_translation import ProductTranslation
|
||||||
from .user import User
|
from .user import User
|
||||||
@@ -82,4 +88,9 @@ __all__ = [
|
|||||||
# Orders
|
# Orders
|
||||||
"Order",
|
"Order",
|
||||||
"OrderItem",
|
"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,
|
String,
|
||||||
Text,
|
Text,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
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_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
|
# Order status
|
||||||
status = Column(String, nullable=False, default="pending", index=True)
|
status = Column(String, nullable=False, default="pending", index=True)
|
||||||
# pending, processing, shipped, delivered, cancelled, refunded
|
# pending, processing, shipped, delivered, cancelled, refunded
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ class Vendor(Base, TimestampMixin):
|
|||||||
"MarketplaceImportJob", back_populates="vendor"
|
"MarketplaceImportJob", back_populates="vendor"
|
||||||
) # Relationship with MarketplaceImportJob model for import jobs related to this 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(
|
domains = relationship(
|
||||||
"VendorDomain",
|
"VendorDomain",
|
||||||
back_populates="vendor",
|
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