refactor: migrate remaining routes to modules and enforce auto-discovery

MIGRATION:
- Delete app/api/v1/vendor/analytics.py (duplicate - analytics module already auto-discovered)
- Move usage routes from app/api/v1/vendor/usage.py to billing module
- Move onboarding routes from app/api/v1/vendor/onboarding.py to marketplace module
- Move features routes to billing module (admin + vendor)
- Move inventory routes to inventory module (admin + vendor)
- Move marketplace/letzshop routes to marketplace module
- Move orders routes to orders module
- Delete legacy letzshop service files (moved to marketplace module)

DOCUMENTATION:
- Add docs/development/migration/module-autodiscovery-migration.md with full migration history
- Update docs/architecture/module-system.md with Entity Auto-Discovery Reference section
- Add detailed sections for each entity type: routes, services, models, schemas, tasks,
  exceptions, templates, static files, locales, configuration

ARCHITECTURE VALIDATION:
- Add MOD-016: Routes must be in modules, not app/api/v1/
- Add MOD-017: Services must be in modules, not app/services/
- Add MOD-018: Tasks must be in modules, not app/tasks/
- Add MOD-019: Schemas must be in modules, not models/schema/
- Update scripts/validate_architecture.py with _validate_legacy_locations method
- Update .architecture-rules/module.yaml with legacy location rules

These rules enforce that all entities must be in self-contained modules.
Legacy locations now trigger ERROR severity violations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 14:25:59 +01:00
parent e2cecff014
commit 401db56258
52 changed files with 1160 additions and 4968 deletions

View File

@@ -2,36 +2,11 @@
"""
Marketplace module route registration.
This module provides marketplace routes with module-based access control.
Structure:
- routes/api/ - REST API endpoints
- routes/pages/ - HTML page rendering (templates)
NOTE: Routers are not eagerly imported here to avoid circular imports.
Import directly from routes/api/admin.py or routes/api/vendor.py instead.
Import routers directly from their respective files:
- from app.modules.marketplace.routes.api.admin import admin_router, admin_letzshop_router
- from app.modules.marketplace.routes.api.vendor import vendor_router, vendor_letzshop_router
"""
def __getattr__(name: str):
"""Lazy import of routers to avoid circular imports."""
if name == "admin_router":
from app.modules.marketplace.routes.api.admin import admin_router
return admin_router
elif name == "admin_letzshop_router":
from app.modules.marketplace.routes.api.admin import admin_letzshop_router
return admin_letzshop_router
elif name == "vendor_router":
from app.modules.marketplace.routes.api.vendor import vendor_router
return vendor_router
elif name == "vendor_letzshop_router":
from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router
return vendor_letzshop_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"]

View File

@@ -2,34 +2,7 @@
"""
Marketplace module API routes.
Provides REST API endpoints for marketplace integration:
- Admin API: Import jobs, vendor directory, marketplace products
- Vendor API: Letzshop sync, product imports, exports
NOTE: Routers are not eagerly imported here to avoid circular imports.
Import directly from admin.py or vendor.py instead.
Import routers directly from their respective files:
- from app.modules.marketplace.routes.api.admin import admin_router, admin_letzshop_router
- from app.modules.marketplace.routes.api.vendor import vendor_router, vendor_letzshop_router
"""
def __getattr__(name: str):
"""Lazy import of routers to avoid circular imports."""
if name == "admin_router":
from app.modules.marketplace.routes.api.admin import admin_router
return admin_router
elif name == "admin_letzshop_router":
from app.modules.marketplace.routes.api.admin import admin_letzshop_router
return admin_letzshop_router
elif name == "vendor_router":
from app.modules.marketplace.routes.api.vendor import vendor_router
return vendor_router
elif name == "vendor_letzshop_router":
from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router
return vendor_letzshop_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["admin_router", "admin_letzshop_router", "vendor_router", "vendor_letzshop_router"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
# app/modules/marketplace/routes/api/admin_marketplace.py
"""
Marketplace import job monitoring endpoints for admin.
All routes require module access control for the 'marketplace' module.
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
AdminMarketplaceImportJobListResponse,
AdminMarketplaceImportJobRequest,
AdminMarketplaceImportJobResponse,
MarketplaceImportErrorListResponse,
MarketplaceImportErrorResponse,
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
from app.modules.analytics.schemas import ImportStatsResponse
admin_marketplace_router = APIRouter(
prefix="/marketplace-import-jobs",
dependencies=[Depends(require_module_access("marketplace"))],
)
logger = logging.getLogger(__name__)
@admin_marketplace_router.get("", response_model=AdminMarketplaceImportJobListResponse)
def get_all_marketplace_import_jobs(
marketplace: str | None = Query(None),
status: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(100, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get all marketplace import jobs with pagination (Admin only)."""
jobs, total = marketplace_import_job_service.get_all_import_jobs_paginated(
db=db,
marketplace=marketplace,
status=status,
page=page,
limit=limit,
)
return AdminMarketplaceImportJobListResponse(
items=[
marketplace_import_job_service.convert_to_admin_response_model(job)
for job in jobs
],
total=total,
page=page,
limit=limit,
)
@admin_marketplace_router.post("", response_model=MarketplaceImportJobResponse)
async def create_marketplace_import_job(
request: AdminMarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create a new marketplace import job (Admin only).
Admins can trigger imports for any vendor by specifying vendor_id.
The import is processed asynchronously in the background.
The `language` parameter specifies the language code for product
translations (e.g., 'en', 'fr', 'de'). Default is 'en'.
"""
vendor = vendor_service.get_vendor_by_id(db, request.vendor_id)
job_request = MarketplaceImportJobRequest(
source_url=request.source_url,
marketplace=request.marketplace,
batch_size=request.batch_size,
language=request.language,
)
job = marketplace_import_job_service.create_import_job(
db=db,
request=job_request,
vendor=vendor,
user=current_admin,
)
db.commit()
logger.info(
f"Admin {current_admin.username} created import job {job.id} "
f"for vendor {vendor.vendor_code} (language={request.language})"
)
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_marketplace_import(
background_tasks=background_tasks,
job_id=job.id,
url=request.source_url,
marketplace=request.marketplace,
vendor_id=vendor.id,
batch_size=request.batch_size or 1000,
language=request.language,
)
# Store Celery task ID if using Celery
if celery_task_id:
job.celery_task_id = celery_task_id
db.commit()
return marketplace_import_job_service.convert_to_response_model(job)
# NOTE: /stats must be defined BEFORE /{job_id} to avoid route conflicts
@admin_marketplace_router.get("/stats", response_model=ImportStatsResponse)
def get_import_statistics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get marketplace import statistics (Admin only)."""
stats = stats_service.get_import_statistics(db)
return ImportStatsResponse(**stats)
@admin_marketplace_router.get("/{job_id}", response_model=AdminMarketplaceImportJobResponse)
def get_marketplace_import_job(
job_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get a single marketplace import job by ID (Admin only)."""
job = marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
return marketplace_import_job_service.convert_to_admin_response_model(job)
@admin_marketplace_router.get("/{job_id}/errors", response_model=MarketplaceImportErrorListResponse)
def get_import_job_errors(
job_id: int,
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
error_type: str | None = Query(None, description="Filter by error type"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get import errors for a specific job (Admin only).
Returns detailed error information including row number, identifier,
error type, error message, and raw row data for review.
"""
# Verify job exists
marketplace_import_job_service.get_import_job_by_id_admin(db, job_id)
# Get errors from service
errors, total = marketplace_import_job_service.get_import_job_errors(
db=db,
job_id=job_id,
error_type=error_type,
page=page,
limit=limit,
)
return MarketplaceImportErrorListResponse(
errors=[MarketplaceImportErrorResponse.model_validate(e) for e in errors],
total=total,
import_job_id=job_id,
)

View File

@@ -2,43 +2,32 @@
"""
Marketplace module vendor routes.
This module wraps the existing vendor marketplace routes and adds
module-based access control. Routes are re-exported from the
original location with the module access dependency.
This module aggregates all marketplace vendor routers into a single router
for auto-discovery. Routes are defined in dedicated files with module-based
access control.
Includes:
- /marketplace/* - Marketplace settings
- /marketplace/* - Marketplace import management
- /letzshop/* - Letzshop integration
"""
import importlib
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from .vendor_marketplace import vendor_marketplace_router
from .vendor_letzshop import vendor_letzshop_router
from .vendor_onboarding import vendor_onboarding_router
from app.api.deps import require_module_access
# Create aggregate router for auto-discovery
# The router is named 'vendor_router' for auto-discovery compatibility
vendor_router = APIRouter()
# Import original routers using importlib to avoid circular imports
# (direct import triggers app.api.v1.vendor.__init__.py which imports us)
_marketplace_module = importlib.import_module("app.api.v1.vendor.marketplace")
_letzshop_module = importlib.import_module("app.api.v1.vendor.letzshop")
marketplace_original_router = _marketplace_module.router
letzshop_original_router = _letzshop_module.router
# Include marketplace import routes
vendor_router.include_router(vendor_marketplace_router)
# Create module-aware router for marketplace
vendor_router = APIRouter(
prefix="/marketplace",
dependencies=[Depends(require_module_access("marketplace"))],
)
# Include letzshop routes
vendor_router.include_router(vendor_letzshop_router)
# Re-export all routes from the original marketplace module
for route in marketplace_original_router.routes:
vendor_router.routes.append(route)
# Include onboarding routes
vendor_router.include_router(vendor_onboarding_router)
# Create separate router for letzshop integration
vendor_letzshop_router = APIRouter(
prefix="/letzshop",
dependencies=[Depends(require_module_access("marketplace"))],
)
for route in letzshop_original_router.routes:
vendor_letzshop_router.routes.append(route)
__all__ = ["vendor_router"]

View File

@@ -0,0 +1,793 @@
# app/modules/marketplace/routes/api/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.
All routes require module access control for the 'marketplace' module.
"""
import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.modules.marketplace.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
OrderNotFoundError,
)
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
FulfillmentConfirmRequest,
FulfillmentOperationResponse,
FulfillmentQueueItemResponse,
FulfillmentQueueListResponse,
FulfillmentRejectRequest,
FulfillmentTrackingRequest,
LetzshopConnectionTestRequest,
LetzshopConnectionTestResponse,
LetzshopCredentialsCreate,
LetzshopCredentialsResponse,
LetzshopCredentialsStatus,
LetzshopCredentialsUpdate,
LetzshopOrderDetailResponse,
LetzshopOrderListResponse,
LetzshopOrderResponse,
LetzshopSuccessResponse,
LetzshopSyncLogListResponse,
LetzshopSyncLogResponse,
LetzshopSyncTriggerRequest,
LetzshopSyncTriggerResponse,
)
vendor_letzshop_router = APIRouter(
prefix="/letzshop",
dependencies=[Depends(require_module_access("marketplace"))],
)
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
# ============================================================================
@vendor_letzshop_router.get("/status", response_model=LetzshopCredentialsStatus)
def get_letzshop_status(
current_user: UserContext = 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)
@vendor_letzshop_router.get("/credentials", response_model=LetzshopCredentialsResponse)
def get_credentials(
current_user: UserContext = 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,
)
@vendor_letzshop_router.post("/credentials", response_model=LetzshopCredentialsResponse)
def save_credentials(
credentials_data: LetzshopCredentialsCreate,
current_user: UserContext = 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,
)
db.commit()
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,
)
@vendor_letzshop_router.patch("/credentials", response_model=LetzshopCredentialsResponse)
def update_credentials(
credentials_data: LetzshopCredentialsUpdate,
current_user: UserContext = 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,
)
db.commit()
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,
)
@vendor_letzshop_router.delete("/credentials", response_model=LetzshopSuccessResponse)
def delete_credentials(
current_user: UserContext = 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)
)
db.commit()
logger.info(f"Vendor user {current_user.email} deleted Letzshop credentials")
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
# ============================================================================
# Connection Testing
# ============================================================================
@vendor_letzshop_router.post("/test", response_model=LetzshopConnectionTestResponse)
def test_connection(
current_user: UserContext = 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,
)
@vendor_letzshop_router.post("/test-key", response_model=LetzshopConnectionTestResponse)
def test_api_key(
test_request: LetzshopConnectionTestRequest,
current_user: UserContext = 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
# ============================================================================
@vendor_letzshop_router.get("/orders", response_model=LetzshopOrderListResponse)
def list_orders(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: str | None = Query(None, description="Filter by order status"),
current_user: UserContext = 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,
status=status,
)
return LetzshopOrderListResponse(
orders=[
LetzshopOrderResponse(
id=order.id,
vendor_id=order.vendor_id,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=f"{order.customer_first_name} {order.customer_last_name}",
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
)
for order in orders
],
total=total,
skip=skip,
limit=limit,
)
@vendor_letzshop_router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse)
def get_order(
order_id: int = Path(..., description="Order ID"),
current_user: UserContext = 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(
# Base fields from LetzshopOrderResponse
id=order.id,
vendor_id=order.vendor_id,
order_number=order.order_number,
external_order_id=order.external_order_id,
external_shipment_id=order.external_shipment_id,
external_order_number=order.external_order_number,
status=order.status,
customer_email=order.customer_email,
customer_name=f"{order.customer_first_name} {order.customer_last_name}",
customer_locale=order.customer_locale,
ship_country_iso=order.ship_country_iso,
bill_country_iso=order.bill_country_iso,
total_amount=order.total_amount,
currency=order.currency,
tracking_number=order.tracking_number,
tracking_provider=order.tracking_provider,
order_date=order.order_date,
confirmed_at=order.confirmed_at,
shipped_at=order.shipped_at,
cancelled_at=order.cancelled_at,
created_at=order.created_at,
updated_at=order.updated_at,
# Detail fields from LetzshopOrderDetailResponse
customer_first_name=order.customer_first_name,
customer_last_name=order.customer_last_name,
customer_phone=order.customer_phone,
ship_first_name=order.ship_first_name,
ship_last_name=order.ship_last_name,
ship_company=order.ship_company,
ship_address_line_1=order.ship_address_line_1,
ship_address_line_2=order.ship_address_line_2,
ship_city=order.ship_city,
ship_postal_code=order.ship_postal_code,
bill_first_name=order.bill_first_name,
bill_last_name=order.bill_last_name,
bill_company=order.bill_company,
bill_address_line_1=order.bill_address_line_1,
bill_address_line_2=order.bill_address_line_2,
bill_city=order.bill_city,
bill_postal_code=order.bill_postal_code,
external_data=order.external_data,
customer_notes=order.customer_notes,
internal_notes=order.internal_notes,
)
@vendor_letzshop_router.post("/orders/import", response_model=LetzshopSyncTriggerResponse)
def import_orders(
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
current_user: UserContext = 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
# ============================================================================
@vendor_letzshop_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: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Confirm inventory units for a Letzshop order.
Raises:
OrderHasUnresolvedExceptionsException: If order has unresolved product exceptions
"""
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))
# Check for unresolved exceptions (blocks confirmation)
unresolved_count = order_item_exception_service.get_unresolved_exception_count(
db, order_id
)
if unresolved_count > 0:
raise OrderHasUnresolvedExceptionsException(order_id, unresolved_count)
# Get inventory unit IDs from request or order items
if confirm_request and confirm_request.inventory_unit_ids:
inventory_unit_ids = confirm_request.inventory_unit_ids
else:
# Get inventory unit IDs from order items' external_item_id
inventory_unit_ids = [
item.external_item_id for item in order.items if item.external_item_id
]
if not inventory_unit_ids:
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))
@vendor_letzshop_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: UserContext = 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 items
if reject_request and reject_request.inventory_unit_ids:
inventory_unit_ids = reject_request.inventory_unit_ids
else:
# Get inventory unit IDs from order items' external_item_id
inventory_unit_ids = [
item.external_item_id for item in order.items if item.external_item_id
]
if not inventory_unit_ids:
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))
@vendor_letzshop_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: UserContext = 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.external_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.external_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
# ============================================================================
@vendor_letzshop_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: UserContext = 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
# ============================================================================
@vendor_letzshop_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: UserContext = 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,
)
# ============================================================================
# Product Export
# ============================================================================
@vendor_letzshop_router.get("/export")
def export_products_letzshop(
language: str = Query(
"en", description="Language for title/description (en, fr, de)"
),
include_inactive: bool = Query(False, description="Include inactive products"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Export vendor products in Letzshop CSV format.
Generates a Google Shopping compatible CSV file for Letzshop marketplace.
The file uses tab-separated values and includes all required Letzshop fields.
**Supported languages:** en, fr, de
**CSV Format:**
- Delimiter: Tab (\\t)
- Encoding: UTF-8
- Fields: id, title, description, price, availability, image_link, etc.
Returns:
CSV file as attachment (vendor_code_letzshop_export.csv)
"""
from fastapi.responses import Response
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
from app.services.vendor_service import vendor_service
vendor_id = current_user.token_vendor_id
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
csv_content = letzshop_export_service.export_vendor_products(
db=db,
vendor_id=vendor_id,
language=language,
include_inactive=include_inactive,
)
filename = f"{vendor.vendor_code.lower()}_letzshop_export.csv"
return Response(
content=csv_content,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)

View File

@@ -0,0 +1,137 @@
# app/modules/marketplace/routes/api/vendor_marketplace.py
"""
Marketplace import endpoints for vendors.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
All routes require module access control for the 'marketplace' module.
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from middleware.decorators import rate_limit
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
MarketplaceImportJobRequest,
MarketplaceImportJobResponse,
)
vendor_marketplace_router = APIRouter(
prefix="/marketplace",
dependencies=[Depends(require_module_access("marketplace"))],
)
logger = logging.getLogger(__name__)
@vendor_marketplace_router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace(
request: MarketplaceImportJobRequest,
background_tasks: BackgroundTasks,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Import products from marketplace CSV with background processing (Protected).
The `language` parameter specifies the language code for product
translations (e.g., 'en', 'fr', 'de'). Default is 'en'.
For multi-language imports, call this endpoint multiple times with
different language codes and CSV files containing translations.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
f"by user {current_user.username} (language={request.language})"
)
# Create import job (vendor comes from token)
import_job = marketplace_import_job_service.create_import_job(
db, request, vendor, current_user
)
db.commit()
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_marketplace_import(
background_tasks=background_tasks,
job_id=import_job.id,
url=request.source_url,
marketplace=request.marketplace,
vendor_id=vendor.id,
batch_size=request.batch_size or 1000,
language=request.language,
)
# Store Celery task ID if using Celery
if celery_task_id:
import_job.celery_task_id = celery_task_id
db.commit()
return MarketplaceImportJobResponse(
job_id=import_job.id,
status="pending",
marketplace=request.marketplace,
vendor_id=import_job.vendor_id,
vendor_code=vendor.vendor_code,
vendor_name=vendor.name,
source_url=request.source_url,
language=request.language,
message=f"Marketplace import started from {request.marketplace}. "
f"Check status with /import-status/{import_job.id}",
imported=0,
updated=0,
total_processed=0,
error_count=0,
created_at=import_job.created_at,
)
@vendor_marketplace_router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
def get_marketplace_import_status(
job_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get status of marketplace import job (Protected)."""
# Service validates that job belongs to vendor and raises UnauthorizedVendorAccessException if not
job = marketplace_import_job_service.get_import_job_for_vendor(
db, job_id, current_user.token_vendor_id
)
return marketplace_import_job_service.convert_to_response_model(job)
@vendor_marketplace_router.get("/imports", response_model=list[MarketplaceImportJobResponse])
def get_marketplace_import_jobs(
marketplace: str | None = Query(None, description="Filter by marketplace"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get marketplace import jobs for current vendor (Protected)."""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
jobs = marketplace_import_job_service.get_import_jobs(
db=db,
vendor=vendor,
user=current_user,
marketplace=marketplace,
skip=skip,
limit=limit,
)
return [
marketplace_import_job_service.convert_to_response_model(job) for job in jobs
]

View File

@@ -0,0 +1,293 @@
# app/modules/marketplace/routes/api/vendor_onboarding.py
"""
Vendor onboarding API endpoints.
Provides endpoints for the 4-step mandatory onboarding wizard:
1. Company Profile Setup
2. Letzshop API Configuration
3. Product & Order Import Configuration
4. Order Sync (historical import)
Migrated from app/api/v1/vendor/onboarding.py to marketplace module.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.onboarding_service import OnboardingService
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
CompanyProfileRequest,
CompanyProfileResponse,
LetzshopApiConfigRequest,
LetzshopApiConfigResponse,
LetzshopApiTestRequest,
LetzshopApiTestResponse,
OnboardingStatusResponse,
OrderSyncCompleteRequest,
OrderSyncCompleteResponse,
OrderSyncProgressResponse,
OrderSyncTriggerRequest,
OrderSyncTriggerResponse,
ProductImportConfigRequest,
ProductImportConfigResponse,
)
vendor_onboarding_router = APIRouter(
prefix="/onboarding",
dependencies=[Depends(require_module_access("marketplace"))],
)
logger = logging.getLogger(__name__)
# =============================================================================
# Status Endpoint
# =============================================================================
@vendor_onboarding_router.get("/status", response_model=OnboardingStatusResponse)
def get_onboarding_status(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current onboarding status.
Returns full status including all step completion states and progress.
"""
service = OnboardingService(db)
status = service.get_status_response(current_user.token_vendor_id)
return status
# =============================================================================
# Step 1: Company Profile
# =============================================================================
@vendor_onboarding_router.get("/step/company-profile")
def get_company_profile(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current company profile data for editing.
Returns pre-filled data from vendor and company records.
"""
service = OnboardingService(db)
return service.get_company_profile_data(current_user.token_vendor_id)
@vendor_onboarding_router.post("/step/company-profile", response_model=CompanyProfileResponse)
def save_company_profile(
request: CompanyProfileRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Save company profile and complete Step 1.
Updates vendor and company records with provided data.
"""
service = OnboardingService(db)
result = service.complete_company_profile(
vendor_id=current_user.token_vendor_id,
company_name=request.company_name,
brand_name=request.brand_name,
description=request.description,
contact_email=request.contact_email,
contact_phone=request.contact_phone,
website=request.website,
business_address=request.business_address,
tax_number=request.tax_number,
default_language=request.default_language,
dashboard_language=request.dashboard_language,
)
db.commit() # Commit at API level for transaction control
return result
# =============================================================================
# Step 2: Letzshop API Configuration
# =============================================================================
@vendor_onboarding_router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse)
def test_letzshop_api(
request: LetzshopApiTestRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Test Letzshop API connection without saving.
Use this to validate API key before saving credentials.
"""
service = OnboardingService(db)
return service.test_letzshop_api(
api_key=request.api_key,
shop_slug=request.shop_slug,
)
@vendor_onboarding_router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse)
def save_letzshop_api(
request: LetzshopApiConfigRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Save Letzshop API credentials and complete Step 2.
Tests connection first, only saves if successful.
"""
service = OnboardingService(db)
result = service.complete_letzshop_api(
vendor_id=current_user.token_vendor_id,
api_key=request.api_key,
shop_slug=request.shop_slug,
letzshop_vendor_id=request.vendor_id,
)
db.commit() # Commit at API level for transaction control
return result
# =============================================================================
# Step 3: Product & Order Import Configuration
# =============================================================================
@vendor_onboarding_router.get("/step/product-import")
def get_product_import_config(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get current product import configuration.
Returns pre-filled CSV URLs and Letzshop feed settings.
"""
service = OnboardingService(db)
return service.get_product_import_config(current_user.token_vendor_id)
@vendor_onboarding_router.post("/step/product-import", response_model=ProductImportConfigResponse)
def save_product_import_config(
request: ProductImportConfigRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Save product import configuration and complete Step 3.
At least one CSV URL must be provided.
"""
service = OnboardingService(db)
result = service.complete_product_import(
vendor_id=current_user.token_vendor_id,
csv_url_fr=request.csv_url_fr,
csv_url_en=request.csv_url_en,
csv_url_de=request.csv_url_de,
default_tax_rate=request.default_tax_rate,
delivery_method=request.delivery_method,
preorder_days=request.preorder_days,
)
db.commit() # Commit at API level for transaction control
return result
# =============================================================================
# Step 4: Order Sync
# =============================================================================
@vendor_onboarding_router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse)
def trigger_order_sync(
request: OrderSyncTriggerRequest,
background_tasks: BackgroundTasks,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Trigger historical order import.
Creates a background job that imports orders from Letzshop.
"""
service = OnboardingService(db)
result = service.trigger_order_sync(
vendor_id=current_user.token_vendor_id,
user_id=current_user.id,
days_back=request.days_back,
include_products=request.include_products,
)
db.commit() # Commit at API level for transaction control
# Queue background task to process the import
if result.get("success") and result.get("job_id"):
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_historical_import(
background_tasks=background_tasks,
job_id=result["job_id"],
vendor_id=current_user.token_vendor_id,
)
# Store Celery task ID if using Celery
if celery_task_id:
from app.modules.marketplace.services.letzshop import LetzshopOrderService
order_service = LetzshopOrderService(db)
order_service.update_job_celery_task_id(result["job_id"], celery_task_id)
logger.info(f"Queued historical import task for job {result['job_id']}")
return result
@vendor_onboarding_router.get(
"/step/order-sync/progress/{job_id}",
response_model=OrderSyncProgressResponse,
)
def get_order_sync_progress(
job_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get order sync job progress.
Poll this endpoint to show progress bar during import.
"""
service = OnboardingService(db)
return service.get_order_sync_progress(
vendor_id=current_user.token_vendor_id,
job_id=job_id,
)
@vendor_onboarding_router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse)
def complete_order_sync(
request: OrderSyncCompleteRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Mark order sync step as complete.
Called after the import job finishes (success or failure).
This also marks the entire onboarding as complete.
"""
service = OnboardingService(db)
result = service.complete_order_sync(
vendor_id=current_user.token_vendor_id,
job_id=request.job_id,
)
db.commit() # Commit at API level for transaction control
return result

View File

@@ -14,7 +14,7 @@ from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.services.letzshop.client_service import LetzshopClient
from .client_service import LetzshopClient
from app.modules.marketplace.models import LetzshopVendorCache
logger = logging.getLogger(__name__)

View File

@@ -40,7 +40,7 @@ def export_vendor_products_to_folder(
Returns:
dict: Export results per language with file paths
"""
from app.services.letzshop_export_service import letzshop_export_service
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
languages = ["en", "fr", "de"]
results = {}
@@ -149,7 +149,7 @@ def export_marketplace_products(
Returns:
dict: Export result with file path
"""
from app.services.letzshop_export_service import letzshop_export_service
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
with self.get_db() as db:
started_at = datetime.now(UTC)

View File

@@ -15,9 +15,11 @@ from typing import Callable
from app.core.celery_config import celery_app
from app.modules.marketplace.models import MarketplaceImportJob, LetzshopHistoricalImportJob
from app.services.admin_notification_service import admin_notification_service
from app.services.letzshop import LetzshopClientError
from app.services.letzshop.credentials_service import LetzshopCredentialsService
from app.services.letzshop.order_service import LetzshopOrderService
from app.modules.marketplace.services.letzshop import (
LetzshopClientError,
LetzshopCredentialsService,
LetzshopOrderService,
)
from app.modules.task_base import ModuleTask
from app.utils.csv_processor import CSVProcessor
from models.database.vendor import Vendor

View File

@@ -11,7 +11,7 @@ from typing import Any
from app.core.celery_config import celery_app
from app.modules.task_base import ModuleTask
from app.services.admin_notification_service import admin_notification_service
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
logger = logging.getLogger(__name__)