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:
@@ -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"]
|
||||
|
||||
1524
app/modules/marketplace/routes/api/admin_letzshop.py
Normal file
1524
app/modules/marketplace/routes/api/admin_letzshop.py
Normal file
File diff suppressed because it is too large
Load Diff
177
app/modules/marketplace/routes/api/admin_marketplace.py
Normal file
177
app/modules/marketplace/routes/api/admin_marketplace.py
Normal 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,
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
793
app/modules/marketplace/routes/api/vendor_letzshop.py
Normal file
793
app/modules/marketplace/routes/api/vendor_letzshop.py
Normal 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}"',
|
||||
},
|
||||
)
|
||||
137
app/modules/marketplace/routes/api/vendor_marketplace.py
Normal file
137
app/modules/marketplace/routes/api/vendor_marketplace.py
Normal 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
|
||||
]
|
||||
293
app/modules/marketplace/routes/api/vendor_onboarding.py
Normal file
293
app/modules/marketplace/routes/api/vendor_onboarding.py
Normal 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
|
||||
Reference in New Issue
Block a user