From 448f01f82b9cc39d3be8c32301be69adb1eb38f8 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 13 Dec 2025 12:19:54 +0100 Subject: [PATCH] feat: add Letzshop bidirectional order integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ecfa503_add_letzshop_integration_tables.py | 179 +++++ app/api/v1/admin/__init__.py | 4 + app/api/v1/admin/letzshop.py | 470 ++++++++++++ app/api/v1/vendor/__init__.py | 2 + app/api/v1/vendor/letzshop.py | 689 ++++++++++++++++++ app/services/letzshop/__init__.py | 45 ++ app/services/letzshop/client.py | 493 +++++++++++++ app/services/letzshop/credentials.py | 413 +++++++++++ app/services/letzshop/order_service.py | 319 ++++++++ app/utils/encryption.py | 187 +++++ docs/guides/letzshop-order-integration.md | 542 ++++++++++++++ mkdocs.yml | 1 + models/database/__init__.py | 11 + models/database/letzshop.py | 221 ++++++ models/database/order.py | 10 + models/database/vendor.py | 8 + models/schema/letzshop.py | 337 +++++++++ .../integration/api/v1/admin/test_letzshop.py | 361 +++++++++ .../api/v1/vendor/test_letzshop.py | 499 +++++++++++++ tests/unit/services/test_letzshop_service.py | 460 ++++++++++++ 20 files changed, 5251 insertions(+) create mode 100644 alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py create mode 100644 app/api/v1/admin/letzshop.py create mode 100644 app/api/v1/vendor/letzshop.py create mode 100644 app/services/letzshop/__init__.py create mode 100644 app/services/letzshop/client.py create mode 100644 app/services/letzshop/credentials.py create mode 100644 app/services/letzshop/order_service.py create mode 100644 app/utils/encryption.py create mode 100644 docs/guides/letzshop-order-integration.md create mode 100644 models/database/letzshop.py create mode 100644 models/schema/letzshop.py create mode 100644 tests/integration/api/v1/admin/test_letzshop.py create mode 100644 tests/integration/api/v1/vendor/test_letzshop.py create mode 100644 tests/unit/services/test_letzshop_service.py diff --git a/alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py b/alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py new file mode 100644 index 00000000..54605a38 --- /dev/null +++ b/alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py @@ -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') diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 6cc3455f..a319ddf7 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -32,6 +32,7 @@ from . import ( companies, content_pages, dashboard, + letzshop, logs, marketplace, monitoring, @@ -114,6 +115,9 @@ router.include_router(vendor_products.router, tags=["admin-vendor-products"]) # Include marketplace monitoring endpoints router.include_router(marketplace.router, tags=["admin-marketplace"]) +# Include Letzshop integration endpoints +router.include_router(letzshop.router, tags=["admin-letzshop"]) + # ============================================================================ # Platform Administration diff --git a/app/api/v1/admin/letzshop.py b/app/api/v1/admin/letzshop.py new file mode 100644 index 00000000..3d46f9db --- /dev/null +++ b/app/api/v1/admin/letzshop.py @@ -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)], + ) diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index 90fefb44..c1867af0 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -21,6 +21,7 @@ from . import ( dashboard, info, inventory, + letzshop, marketplace, media, notifications, @@ -59,6 +60,7 @@ router.include_router(customers.router, tags=["vendor-customers"]) router.include_router(team.router, tags=["vendor-team"]) router.include_router(inventory.router, tags=["vendor-inventory"]) router.include_router(marketplace.router, tags=["vendor-marketplace"]) +router.include_router(letzshop.router, tags=["vendor-letzshop"]) # Services (with prefixes: /payments/*, /media/*, etc.) router.include_router(payments.router, tags=["vendor-payments"]) diff --git a/app/api/v1/vendor/letzshop.py b/app/api/v1/vendor/letzshop.py new file mode 100644 index 00000000..00613ca9 --- /dev/null +++ b/app/api/v1/vendor/letzshop.py @@ -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, + ) diff --git a/app/services/letzshop/__init__.py b/app/services/letzshop/__init__.py new file mode 100644 index 00000000..ea8d083e --- /dev/null +++ b/app/services/letzshop/__init__.py @@ -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", +] diff --git a/app/services/letzshop/client.py b/app/services/letzshop/client.py new file mode 100644 index 00000000..6bf03d91 --- /dev/null +++ b/app/services/letzshop/client.py @@ -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", {}) diff --git a/app/services/letzshop/credentials.py b/app/services/letzshop/credentials.py new file mode 100644 index 00000000..7584fc28 --- /dev/null +++ b/app/services/letzshop/credentials.py @@ -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, + } diff --git a/app/services/letzshop/order_service.py b/app/services/letzshop/order_service.py new file mode 100644 index 00000000..467396b2 --- /dev/null +++ b/app/services/letzshop/order_service.py @@ -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 diff --git a/app/utils/encryption.py b/app/utils/encryption.py new file mode 100644 index 00000000..d27635ba --- /dev/null +++ b/app/utils/encryption.py @@ -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) diff --git a/docs/guides/letzshop-order-integration.md b/docs/guides/letzshop-order-integration.md new file mode 100644 index 00000000..0e2213a2 --- /dev/null +++ b/docs/guides/letzshop-order-integration.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index ee2d496e..9344d1bb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -186,6 +186,7 @@ nav: - Shop Setup: guides/shop-setup.md - CSV Import: guides/csv-import.md - Marketplace Integration: guides/marketplace-integration.md + - Letzshop Order Integration: guides/letzshop-order-integration.md # ============================================ # TROUBLESHOOTING diff --git a/models/database/__init__.py b/models/database/__init__.py index d34d78a4..029545d3 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -28,6 +28,12 @@ from .marketplace_product import ( ) from .marketplace_product_translation import MarketplaceProductTranslation from .order import Order, OrderItem +from .letzshop import ( + LetzshopFulfillmentQueue, + LetzshopOrder, + LetzshopSyncLog, + VendorLetzshopCredentials, +) from .product import Product from .product_translation import ProductTranslation from .user import User @@ -82,4 +88,9 @@ __all__ = [ # Orders "Order", "OrderItem", + # Letzshop Integration + "VendorLetzshopCredentials", + "LetzshopOrder", + "LetzshopFulfillmentQueue", + "LetzshopSyncLog", ] diff --git a/models/database/letzshop.py b/models/database/letzshop.py new file mode 100644 index 00000000..b8862864 --- /dev/null +++ b/models/database/letzshop.py @@ -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"" + + +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"" + + +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"" + + +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"" diff --git a/models/database/order.py b/models/database/order.py index b4550e5e..33ec3fa7 100644 --- a/models/database/order.py +++ b/models/database/order.py @@ -10,6 +10,7 @@ from sqlalchemy import ( String, Text, ) +from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base @@ -29,6 +30,15 @@ class Order(Base, TimestampMixin): order_number = Column(String, nullable=False, unique=True, index=True) + # Order channel/source + channel = Column( + String(50), default="direct", index=True + ) # direct, letzshop, amazon, etc. + external_order_id = Column( + String(100), nullable=True, index=True + ) # External order reference + external_channel_data = Column(JSON, nullable=True) # Channel-specific metadata + # Order status status = Column(String, nullable=False, default="pending", index=True) # pending, processing, shipped, delivered, cancelled, refunded diff --git a/models/database/vendor.py b/models/database/vendor.py index 2de495e6..17d282c3 100644 --- a/models/database/vendor.py +++ b/models/database/vendor.py @@ -96,6 +96,14 @@ class Vendor(Base, TimestampMixin): "MarketplaceImportJob", back_populates="vendor" ) # Relationship with MarketplaceImportJob model for import jobs related to this vendor + # Letzshop integration credentials (one-to-one) + letzshop_credentials = relationship( + "VendorLetzshopCredentials", + back_populates="vendor", + uselist=False, + cascade="all, delete-orphan", + ) + domains = relationship( "VendorDomain", back_populates="vendor", diff --git a/models/schema/letzshop.py b/models/schema/letzshop.py new file mode 100644 index 00000000..f795ba03 --- /dev/null +++ b/models/schema/letzshop.py @@ -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 diff --git a/tests/integration/api/v1/admin/test_letzshop.py b/tests/integration/api/v1/admin/test_letzshop.py new file mode 100644 index 00000000..aff8dcb8 --- /dev/null +++ b/tests/integration/api/v1/admin/test_letzshop.py @@ -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 diff --git a/tests/integration/api/v1/vendor/test_letzshop.py b/tests/integration/api/v1/vendor/test_letzshop.py new file mode 100644 index 00000000..18e8dad7 --- /dev/null +++ b/tests/integration/api/v1/vendor/test_letzshop.py @@ -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" diff --git a/tests/unit/services/test_letzshop_service.py b/tests/unit/services/test_letzshop_service.py new file mode 100644 index 00000000..dae9d9b0 --- /dev/null +++ b/tests/unit/services/test_letzshop_service.py @@ -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)