refactor: switch to full auto-discovery for module API routes
- Enhanced route discovery system with ROUTE_CONFIG support for custom
prefix, tags, and priority
- Added get_admin_api_routes() and get_vendor_api_routes() helpers that
return routes sorted by priority
- Added fallback discovery for routes/{frontend}.py when routes/api/
doesn't exist
- Updated CMS module with ROUTE_CONFIG (prefix: /content-pages,
priority: 100) to register last for catch-all routes
- Moved customers routes from routes/ to routes/api/ directory
- Updated orders module to aggregate exception routers into main routers
- Removed manual module router imports from admin and vendor API init
files, replaced with auto-discovery loop
Modules now auto-discovered: billing, inventory, orders, marketplace,
cms, customers, analytics, loyalty, messaging, monitoring, dev-tools
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,22 +25,18 @@ Routes can be module-gated using require_module_access() dependency.
|
|||||||
For multi-tenant apps, module enablement is checked at request time
|
For multi-tenant apps, module enablement is checked at request time
|
||||||
based on platform context (not at route registration time).
|
based on platform context (not at route registration time).
|
||||||
|
|
||||||
Extracted modules (app/modules/{module}/routes/):
|
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/admin.py):
|
||||||
- billing: Subscription tiers, vendor billing, invoices
|
- billing: Subscription tiers, vendor billing, invoices
|
||||||
- inventory: Stock management, inventory tracking
|
- inventory: Stock management, inventory tracking
|
||||||
- orders: Order management, fulfillment, exceptions
|
- orders: Order management, fulfillment, exceptions
|
||||||
- marketplace: Letzshop integration, product sync
|
- marketplace: Letzshop integration, product sync
|
||||||
|
- cms: Content pages management
|
||||||
Module extraction pattern:
|
- customers: Customer management
|
||||||
1. Create app/modules/{module}/ directory
|
|
||||||
2. Create routes/admin.py with require_module_access("{module}") dependency
|
|
||||||
3. Import module router here and include it
|
|
||||||
4. Comment out legacy router include
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
# Import all admin routers
|
# Import all admin routers (legacy routes that haven't been migrated to modules)
|
||||||
from . import (
|
from . import (
|
||||||
admin_users,
|
admin_users,
|
||||||
audit,
|
audit,
|
||||||
@@ -48,16 +44,11 @@ from . import (
|
|||||||
background_tasks,
|
background_tasks,
|
||||||
code_quality,
|
code_quality,
|
||||||
companies,
|
companies,
|
||||||
# content_pages - moved to app.modules.cms.routes.api.admin
|
|
||||||
# customers - moved to app.modules.customers.routes.admin
|
|
||||||
dashboard,
|
dashboard,
|
||||||
email_templates,
|
email_templates,
|
||||||
features,
|
features,
|
||||||
images,
|
images,
|
||||||
inventory,
|
|
||||||
letzshop,
|
|
||||||
logs,
|
logs,
|
||||||
marketplace,
|
|
||||||
media,
|
media,
|
||||||
menu_config,
|
menu_config,
|
||||||
messages,
|
messages,
|
||||||
@@ -65,8 +56,6 @@ from . import (
|
|||||||
modules,
|
modules,
|
||||||
monitoring,
|
monitoring,
|
||||||
notifications,
|
notifications,
|
||||||
order_item_exceptions,
|
|
||||||
orders,
|
|
||||||
platform_health,
|
platform_health,
|
||||||
platforms,
|
platforms,
|
||||||
products,
|
products,
|
||||||
@@ -80,21 +69,6 @@ from . import (
|
|||||||
vendors,
|
vendors,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import extracted module routers
|
|
||||||
# NOTE: Import directly from admin.py files to avoid circular imports through __init__.py
|
|
||||||
from app.modules.billing.routes.api.admin import admin_router as billing_admin_router
|
|
||||||
from app.modules.inventory.routes.admin import admin_router as inventory_admin_router
|
|
||||||
from app.modules.orders.routes.admin import admin_router as orders_admin_router
|
|
||||||
from app.modules.orders.routes.admin import admin_exceptions_router as orders_exceptions_router
|
|
||||||
from app.modules.marketplace.routes.api.admin import admin_router as marketplace_admin_router
|
|
||||||
from app.modules.marketplace.routes.api.admin import admin_letzshop_router as letzshop_admin_router
|
|
||||||
|
|
||||||
# CMS module router
|
|
||||||
from app.modules.cms.routes.api.admin import router as cms_admin_router
|
|
||||||
|
|
||||||
# Customers module router
|
|
||||||
from app.modules.customers.routes.admin import admin_router as customers_admin_router
|
|
||||||
|
|
||||||
# Create admin router
|
# Create admin router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -123,12 +97,6 @@ router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
|
|||||||
# Include vendor themes management endpoints
|
# Include vendor themes management endpoints
|
||||||
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
|
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
|
||||||
|
|
||||||
# Include CMS module router (self-contained module)
|
|
||||||
router.include_router(
|
|
||||||
cms_admin_router, prefix="/content-pages", tags=["admin-content-pages"]
|
|
||||||
)
|
|
||||||
# Legacy: content_pages.router moved to app.modules.cms.routes.api.admin
|
|
||||||
|
|
||||||
# Include platforms management endpoints (multi-platform CMS)
|
# Include platforms management endpoints (multi-platform CMS)
|
||||||
router.include_router(platforms.router, tags=["admin-platforms"])
|
router.include_router(platforms.router, tags=["admin-platforms"])
|
||||||
|
|
||||||
@@ -152,10 +120,6 @@ router.include_router(users.router, tags=["admin-users"])
|
|||||||
# Include admin user management endpoints (super admin only)
|
# Include admin user management endpoints (super admin only)
|
||||||
router.include_router(admin_users.router, tags=["admin-admin-users"])
|
router.include_router(admin_users.router, tags=["admin-admin-users"])
|
||||||
|
|
||||||
# Include customers module router (with module access control)
|
|
||||||
router.include_router(customers_admin_router, tags=["admin-customers"])
|
|
||||||
# Legacy: router.include_router(customers.router, tags=["admin-customers"])
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Dashboard & Statistics
|
# Dashboard & Statistics
|
||||||
@@ -166,7 +130,7 @@ router.include_router(dashboard.router, tags=["admin-dashboard"])
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Vendor Operations (Product Catalog, Inventory & Orders) - Module-gated
|
# Vendor Operations (Product Catalog)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Include marketplace product catalog management endpoints
|
# Include marketplace product catalog management endpoints
|
||||||
@@ -175,27 +139,6 @@ router.include_router(products.router, tags=["admin-marketplace-products"])
|
|||||||
# Include vendor product catalog management endpoints
|
# Include vendor product catalog management endpoints
|
||||||
router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
||||||
|
|
||||||
# Include inventory module router (with module access control)
|
|
||||||
router.include_router(inventory_admin_router, tags=["admin-inventory"])
|
|
||||||
# Legacy: router.include_router(inventory.router, tags=["admin-inventory"])
|
|
||||||
|
|
||||||
# Include orders module router (with module access control)
|
|
||||||
router.include_router(orders_admin_router, tags=["admin-orders"])
|
|
||||||
router.include_router(orders_exceptions_router, tags=["admin-order-exceptions"])
|
|
||||||
# Legacy: router.include_router(orders.router, tags=["admin-orders"])
|
|
||||||
# Legacy: router.include_router(order_item_exceptions.router, tags=["admin-order-exceptions"])
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Marketplace & Imports (Module-gated)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Include marketplace module router (with module access control)
|
|
||||||
router.include_router(marketplace_admin_router, tags=["admin-marketplace"])
|
|
||||||
router.include_router(letzshop_admin_router, tags=["admin-letzshop"])
|
|
||||||
# Legacy: router.include_router(marketplace.router, tags=["admin-marketplace"])
|
|
||||||
# Legacy: router.include_router(letzshop.router, tags=["admin-letzshop"])
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Platform Administration
|
# Platform Administration
|
||||||
@@ -236,21 +179,6 @@ router.include_router(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Billing & Subscriptions (Module-gated)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Include billing module router (with module access control)
|
|
||||||
# This router checks if the 'billing' module is enabled for the platform
|
|
||||||
router.include_router(billing_admin_router, tags=["admin-billing"])
|
|
||||||
|
|
||||||
# Legacy subscriptions router (to be removed once billing module is fully tested)
|
|
||||||
# router.include_router(subscriptions.router, tags=["admin-subscriptions"])
|
|
||||||
|
|
||||||
# Include feature management endpoints
|
|
||||||
router.include_router(features.router, tags=["admin-features"])
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Code Quality & Architecture
|
# Code Quality & Architecture
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -263,5 +191,31 @@ router.include_router(
|
|||||||
# Include test runner endpoints
|
# Include test runner endpoints
|
||||||
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
|
router.include_router(tests.router, prefix="/tests", tags=["admin-tests"])
|
||||||
|
|
||||||
|
# Include feature management endpoints
|
||||||
|
router.include_router(features.router, tags=["admin-features"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Auto-discovered Module Routes
|
||||||
|
# ============================================================================
|
||||||
|
# Routes from self-contained modules are auto-discovered and registered.
|
||||||
|
# Modules include: billing, inventory, orders, marketplace, cms, customers
|
||||||
|
|
||||||
|
from app.modules.routes import get_admin_api_routes
|
||||||
|
|
||||||
|
for route_info in get_admin_api_routes():
|
||||||
|
# Only pass prefix if custom_prefix is set (router already has internal prefix)
|
||||||
|
if route_info.custom_prefix:
|
||||||
|
router.include_router(
|
||||||
|
route_info.router,
|
||||||
|
prefix=route_info.custom_prefix,
|
||||||
|
tags=route_info.tags,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
router.include_router(
|
||||||
|
route_info.router,
|
||||||
|
tags=route_info.tags,
|
||||||
|
)
|
||||||
|
|
||||||
# Export the router
|
# Export the router
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
90
app/api/v1/vendor/__init__.py
vendored
90
app/api/v1/vendor/__init__.py
vendored
@@ -14,43 +14,32 @@ Routes can be module-gated using require_module_access() dependency.
|
|||||||
For multi-tenant apps, module enablement is checked at request time
|
For multi-tenant apps, module enablement is checked at request time
|
||||||
based on platform context (not at route registration time).
|
based on platform context (not at route registration time).
|
||||||
|
|
||||||
Extracted modules (app/modules/{module}/routes/):
|
Self-contained modules (auto-discovered from app/modules/{module}/routes/api/vendor.py):
|
||||||
- billing: Subscription tiers, vendor billing, invoices
|
- billing: Subscription tiers, vendor billing, invoices
|
||||||
- inventory: Stock management, inventory tracking
|
- inventory: Stock management, inventory tracking
|
||||||
- orders: Order management, fulfillment, exceptions
|
- orders: Order management, fulfillment, exceptions
|
||||||
- marketplace: Letzshop integration, product sync
|
- marketplace: Letzshop integration, product sync
|
||||||
|
- cms: Content pages management
|
||||||
Module extraction pattern:
|
- customers: Customer management
|
||||||
1. Create app/modules/{module}/ directory
|
|
||||||
2. Create routes/vendor.py with require_module_access("{module}") dependency
|
|
||||||
3. Import module router here and include it
|
|
||||||
4. Comment out legacy router include
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
# Import all sub-routers (JSON API only)
|
# Import all sub-routers (legacy routes that haven't been migrated to modules)
|
||||||
from . import (
|
from . import (
|
||||||
analytics,
|
analytics,
|
||||||
auth,
|
auth,
|
||||||
billing,
|
billing,
|
||||||
# content_pages - moved to app.modules.cms.routes.api.vendor
|
|
||||||
# customers - moved to app.modules.customers.routes.vendor
|
|
||||||
dashboard,
|
dashboard,
|
||||||
email_settings,
|
email_settings,
|
||||||
email_templates,
|
email_templates,
|
||||||
features,
|
features,
|
||||||
info,
|
info,
|
||||||
inventory,
|
|
||||||
invoices,
|
invoices,
|
||||||
letzshop,
|
|
||||||
marketplace,
|
|
||||||
media,
|
media,
|
||||||
messages,
|
messages,
|
||||||
notifications,
|
notifications,
|
||||||
onboarding,
|
onboarding,
|
||||||
order_item_exceptions,
|
|
||||||
orders,
|
|
||||||
payments,
|
payments,
|
||||||
products,
|
products,
|
||||||
profile,
|
profile,
|
||||||
@@ -59,21 +48,6 @@ from . import (
|
|||||||
usage,
|
usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import extracted module routers
|
|
||||||
# NOTE: Import directly from vendor.py files to avoid circular imports through __init__.py
|
|
||||||
from app.modules.billing.routes.api.vendor import vendor_router as billing_vendor_router
|
|
||||||
from app.modules.inventory.routes.vendor import vendor_router as inventory_vendor_router
|
|
||||||
from app.modules.orders.routes.vendor import vendor_router as orders_vendor_router
|
|
||||||
from app.modules.orders.routes.vendor import vendor_exceptions_router as orders_exceptions_router
|
|
||||||
from app.modules.marketplace.routes.api.vendor import vendor_router as marketplace_vendor_router
|
|
||||||
from app.modules.marketplace.routes.api.vendor import vendor_letzshop_router as letzshop_vendor_router
|
|
||||||
|
|
||||||
# CMS module router
|
|
||||||
from app.modules.cms.routes.api.vendor import router as cms_vendor_router
|
|
||||||
|
|
||||||
# Customers module router
|
|
||||||
from app.modules.customers.routes.vendor import vendor_router as customers_vendor_router
|
|
||||||
|
|
||||||
# Create vendor router
|
# Create vendor router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -97,50 +71,44 @@ router.include_router(email_templates.router, tags=["vendor-email-templates"])
|
|||||||
router.include_router(email_settings.router, tags=["vendor-email-settings"])
|
router.include_router(email_settings.router, tags=["vendor-email-settings"])
|
||||||
router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
router.include_router(onboarding.router, tags=["vendor-onboarding"])
|
||||||
|
|
||||||
# Business operations (with prefixes: /products/*, /orders/*, etc.)
|
# Business operations (with prefixes: /products/*, etc.)
|
||||||
router.include_router(products.router, tags=["vendor-products"])
|
router.include_router(products.router, tags=["vendor-products"])
|
||||||
|
|
||||||
# Include orders module router (with module access control)
|
|
||||||
router.include_router(orders_vendor_router, tags=["vendor-orders"])
|
|
||||||
router.include_router(orders_exceptions_router, tags=["vendor-order-exceptions"])
|
|
||||||
# Legacy: router.include_router(orders.router, tags=["vendor-orders"])
|
|
||||||
# Legacy: router.include_router(order_item_exceptions.router, tags=["vendor-order-exceptions"])
|
|
||||||
|
|
||||||
router.include_router(invoices.router, tags=["vendor-invoices"])
|
router.include_router(invoices.router, tags=["vendor-invoices"])
|
||||||
|
|
||||||
# Include customers module router (with module access control)
|
|
||||||
router.include_router(customers_vendor_router, tags=["vendor-customers"])
|
|
||||||
# Legacy: router.include_router(customers.router, tags=["vendor-customers"])
|
|
||||||
|
|
||||||
router.include_router(team.router, tags=["vendor-team"])
|
router.include_router(team.router, tags=["vendor-team"])
|
||||||
|
|
||||||
# Include inventory module router (with module access control)
|
|
||||||
router.include_router(inventory_vendor_router, tags=["vendor-inventory"])
|
|
||||||
# Legacy: router.include_router(inventory.router, tags=["vendor-inventory"])
|
|
||||||
|
|
||||||
# Include marketplace module router (with module access control)
|
|
||||||
router.include_router(marketplace_vendor_router, tags=["vendor-marketplace"])
|
|
||||||
router.include_router(letzshop_vendor_router, tags=["vendor-letzshop"])
|
|
||||||
# Legacy: router.include_router(marketplace.router, tags=["vendor-marketplace"])
|
|
||||||
# Legacy: router.include_router(letzshop.router, tags=["vendor-letzshop"])
|
|
||||||
|
|
||||||
# Services (with prefixes: /payments/*, /media/*, etc.)
|
# Services (with prefixes: /payments/*, /media/*, etc.)
|
||||||
router.include_router(payments.router, tags=["vendor-payments"])
|
router.include_router(payments.router, tags=["vendor-payments"])
|
||||||
router.include_router(media.router, tags=["vendor-media"])
|
router.include_router(media.router, tags=["vendor-media"])
|
||||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||||
router.include_router(messages.router, tags=["vendor-messages"])
|
router.include_router(messages.router, tags=["vendor-messages"])
|
||||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
router.include_router(analytics.router, tags=["vendor-analytics"])
|
||||||
|
|
||||||
# Include billing module router (with module access control)
|
|
||||||
router.include_router(billing_vendor_router, tags=["vendor-billing"])
|
|
||||||
# Legacy: router.include_router(billing.router, tags=["vendor-billing"])
|
|
||||||
|
|
||||||
router.include_router(features.router, tags=["vendor-features"])
|
router.include_router(features.router, tags=["vendor-features"])
|
||||||
router.include_router(usage.router, tags=["vendor-usage"])
|
router.include_router(usage.router, tags=["vendor-usage"])
|
||||||
|
|
||||||
# CMS module router (self-contained module)
|
|
||||||
router.include_router(cms_vendor_router, tags=["vendor-content-pages"])
|
# ============================================================================
|
||||||
# Legacy: content_pages.router moved to app.modules.cms.routes.api.vendor
|
# Auto-discovered Module Routes
|
||||||
|
# ============================================================================
|
||||||
|
# Routes from self-contained modules are auto-discovered and registered.
|
||||||
|
# Modules include: billing, inventory, orders, marketplace, cms, customers
|
||||||
|
# Routes are sorted by priority, so catch-all routes (CMS) come last.
|
||||||
|
|
||||||
|
from app.modules.routes import get_vendor_api_routes
|
||||||
|
|
||||||
|
for route_info in get_vendor_api_routes():
|
||||||
|
# Only pass prefix if custom_prefix is set (router already has internal prefix)
|
||||||
|
if route_info.custom_prefix:
|
||||||
|
router.include_router(
|
||||||
|
route_info.router,
|
||||||
|
prefix=route_info.custom_prefix,
|
||||||
|
tags=route_info.tags,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
router.include_router(
|
||||||
|
route_info.router,
|
||||||
|
tags=route_info.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
|
# Vendor info endpoint - MUST BE LAST! Has catch-all GET /{vendor_code}
|
||||||
router.include_router(info.router, tags=["vendor-info"])
|
router.include_router(info.router, tags=["vendor-info"])
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ from app.modules.billing.models import FeatureCode
|
|||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
|
prefix="/analytics",
|
||||||
dependencies=[Depends(require_module_access("analytics"))],
|
dependencies=[Depends(require_module_access("analytics"))],
|
||||||
)
|
)
|
||||||
|
vendor_router = router # Alias for discovery
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ from app.modules.cms.schemas import (
|
|||||||
from app.modules.cms.services import content_page_service
|
from app.modules.cms.services import content_page_service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
|
# Route configuration for auto-discovery
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "/content-pages",
|
||||||
|
"tags": ["admin-content-pages"],
|
||||||
|
"priority": 100, # Register last (CMS has catch-all slug routes)
|
||||||
|
}
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
admin_router = router # Alias for discovery compatibility
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,17 @@ from app.modules.cms.services import content_page_service
|
|||||||
from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
|
from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
|
||||||
from models.database.user import User
|
from models.database.user import User
|
||||||
|
|
||||||
|
# Route configuration for auto-discovery
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "/content-pages",
|
||||||
|
"tags": ["vendor-content-pages"],
|
||||||
|
"priority": 100, # Register last (CMS has catch-all slug routes)
|
||||||
|
}
|
||||||
|
|
||||||
vendor_service = VendorService()
|
vendor_service = VendorService()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
vendor_router = router # Alias for discovery compatibility
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,20 @@ from app.modules.customers.routes.api.storefront import router as storefront_rou
|
|||||||
# Tag for OpenAPI documentation
|
# Tag for OpenAPI documentation
|
||||||
STOREFRONT_TAG = "Customer Account (Storefront)"
|
STOREFRONT_TAG = "Customer Account (Storefront)"
|
||||||
|
|
||||||
__all__ = ["storefront_router", "STOREFRONT_TAG"]
|
__all__ = [
|
||||||
|
"storefront_router",
|
||||||
|
"STOREFRONT_TAG",
|
||||||
|
"admin_router",
|
||||||
|
"vendor_router",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
"""Lazy import routers to avoid circular dependencies."""
|
||||||
|
if name == "admin_router":
|
||||||
|
from app.modules.customers.routes.api.admin import admin_router
|
||||||
|
return admin_router
|
||||||
|
elif name == "vendor_router":
|
||||||
|
from app.modules.customers.routes.api.vendor import vendor_router
|
||||||
|
return vendor_router
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/modules/customers/routes/admin.py
|
# app/modules/customers/routes/api/admin.py
|
||||||
"""
|
"""
|
||||||
Customer management endpoints for admin.
|
Customer management endpoints for admin.
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/modules/customers/routes/vendor.py
|
# app/modules/customers/routes/api/vendor.py
|
||||||
"""
|
"""
|
||||||
Vendor customer management endpoints.
|
Vendor customer management endpoints.
|
||||||
|
|
||||||
@@ -1,9 +1,35 @@
|
|||||||
# app/modules/orders/routes/api/__init__.py
|
# app/modules/orders/routes/api/__init__.py
|
||||||
"""Orders module API routes."""
|
"""
|
||||||
|
Orders module API routes.
|
||||||
|
|
||||||
|
Provides REST API endpoints for order management:
|
||||||
|
- Admin API: Platform-wide order management (includes exceptions)
|
||||||
|
- Vendor API: Vendor-specific order operations (includes exceptions)
|
||||||
|
- Storefront API: Customer-facing order endpoints
|
||||||
|
|
||||||
|
Note: admin_router and vendor_router now aggregate their respective
|
||||||
|
exception routers, so only these two routers need to be registered.
|
||||||
|
"""
|
||||||
|
|
||||||
from app.modules.orders.routes.api.storefront import router as storefront_router
|
from app.modules.orders.routes.api.storefront import router as storefront_router
|
||||||
|
|
||||||
# Tag for OpenAPI documentation
|
# Tag for OpenAPI documentation
|
||||||
STOREFRONT_TAG = "Orders (Storefront)"
|
STOREFRONT_TAG = "Orders (Storefront)"
|
||||||
|
|
||||||
__all__ = ["storefront_router", "STOREFRONT_TAG"]
|
__all__ = [
|
||||||
|
"storefront_router",
|
||||||
|
"STOREFRONT_TAG",
|
||||||
|
"admin_router",
|
||||||
|
"vendor_router",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
"""Lazy import routers to avoid circular dependencies."""
|
||||||
|
if name == "admin_router":
|
||||||
|
from app.modules.orders.routes.api.admin import admin_router
|
||||||
|
return admin_router
|
||||||
|
elif name == "vendor_router":
|
||||||
|
from app.modules.orders.routes.api.vendor import vendor_router
|
||||||
|
return vendor_router
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
214
app/modules/orders/routes/api/admin.py
Normal file
214
app/modules/orders/routes/api/admin.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# app/modules/orders/routes/api/admin.py
|
||||||
|
"""
|
||||||
|
Admin order management endpoints.
|
||||||
|
|
||||||
|
Provides order management capabilities for administrators:
|
||||||
|
- View orders across all vendors
|
||||||
|
- View vendor-specific orders
|
||||||
|
- Update order status on behalf of vendors
|
||||||
|
- Order statistics and reporting
|
||||||
|
|
||||||
|
Admin Context: Uses admin JWT authentication.
|
||||||
|
Vendor selection is passed as a request parameter.
|
||||||
|
|
||||||
|
This router aggregates both order routes and exception routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, 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.services.order_service import order_service
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
from app.modules.orders.schemas import (
|
||||||
|
AdminOrderItem,
|
||||||
|
AdminOrderListResponse,
|
||||||
|
AdminOrderStats,
|
||||||
|
AdminOrderStatusUpdate,
|
||||||
|
AdminVendorsWithOrdersResponse,
|
||||||
|
MarkAsShippedRequest,
|
||||||
|
OrderDetailResponse,
|
||||||
|
ShippingLabelInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base router for orders
|
||||||
|
_orders_router = APIRouter(
|
||||||
|
prefix="/orders",
|
||||||
|
dependencies=[Depends(require_module_access("orders"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate router that includes both orders and exceptions
|
||||||
|
admin_router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# List & Statistics Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("", response_model=AdminOrderListResponse)
|
||||||
|
def get_all_orders(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||||
|
status: str | None = Query(None, description="Filter by status"),
|
||||||
|
channel: str | None = Query(None, description="Filter by channel"),
|
||||||
|
search: str | None = Query(None, description="Search by order number or customer"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get orders across all vendors with filtering.
|
||||||
|
|
||||||
|
Allows admins to view and filter orders across the platform.
|
||||||
|
"""
|
||||||
|
orders, total = order_service.get_all_orders_admin(
|
||||||
|
db=db,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
status=status,
|
||||||
|
channel=channel,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminOrderListResponse(
|
||||||
|
orders=[AdminOrderItem(**order) for order in orders],
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("/stats", response_model=AdminOrderStats)
|
||||||
|
def get_order_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""Get platform-wide order statistics."""
|
||||||
|
return order_service.get_order_stats_admin(db)
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("/vendors", response_model=AdminVendorsWithOrdersResponse)
|
||||||
|
def get_vendors_with_orders(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""Get list of vendors that have orders."""
|
||||||
|
vendors = order_service.get_vendors_with_orders_admin(db)
|
||||||
|
return AdminVendorsWithOrdersResponse(vendors=vendors)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Order Detail & Update Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||||
|
def get_order_detail(
|
||||||
|
order_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""Get order details including items and addresses."""
|
||||||
|
order = order_service.get_order_by_id_admin(db, order_id)
|
||||||
|
|
||||||
|
# Enrich with vendor info
|
||||||
|
response = OrderDetailResponse.model_validate(order)
|
||||||
|
if order.vendor:
|
||||||
|
response.vendor_name = order.vendor.name
|
||||||
|
response.vendor_code = order.vendor.vendor_code
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.patch("/{order_id}/status", response_model=OrderDetailResponse)
|
||||||
|
def update_order_status(
|
||||||
|
order_id: int,
|
||||||
|
status_update: AdminOrderStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update order status.
|
||||||
|
|
||||||
|
Admin can update status and add tracking number.
|
||||||
|
Status changes are logged with optional reason.
|
||||||
|
"""
|
||||||
|
order = order_service.update_order_status_admin(
|
||||||
|
db=db,
|
||||||
|
order_id=order_id,
|
||||||
|
status=status_update.status,
|
||||||
|
tracking_number=status_update.tracking_number,
|
||||||
|
reason=status_update.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.email} updated order {order.order_number} "
|
||||||
|
f"status to {status_update.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.post("/{order_id}/ship", response_model=OrderDetailResponse)
|
||||||
|
def mark_order_as_shipped(
|
||||||
|
order_id: int,
|
||||||
|
ship_request: MarkAsShippedRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Mark an order as shipped with optional tracking information.
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
- Sets order status to 'shipped'
|
||||||
|
- Sets shipped_at timestamp
|
||||||
|
- Optionally stores tracking number, URL, and carrier
|
||||||
|
"""
|
||||||
|
order = order_service.mark_as_shipped_admin(
|
||||||
|
db=db,
|
||||||
|
order_id=order_id,
|
||||||
|
tracking_number=ship_request.tracking_number,
|
||||||
|
tracking_url=ship_request.tracking_url,
|
||||||
|
shipping_carrier=ship_request.shipping_carrier,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.email} marked order {order.order_number} as shipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("/{order_id}/shipping-label", response_model=ShippingLabelInfo)
|
||||||
|
def get_shipping_label_info(
|
||||||
|
order_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get shipping label information for an order.
|
||||||
|
|
||||||
|
Returns the shipment number, carrier, and generated label URL
|
||||||
|
based on carrier settings.
|
||||||
|
"""
|
||||||
|
return order_service.get_shipping_label_info_admin(db, order_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Aggregate routers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Import exceptions router
|
||||||
|
from app.modules.orders.routes.api.admin_exceptions import admin_exceptions_router
|
||||||
|
|
||||||
|
# Include both routers into the aggregate admin_router
|
||||||
|
admin_router.include_router(_orders_router, tags=["admin-orders"])
|
||||||
|
admin_router.include_router(admin_exceptions_router, tags=["admin-order-exceptions"])
|
||||||
256
app/modules/orders/routes/api/admin_exceptions.py
Normal file
256
app/modules/orders/routes/api/admin_exceptions.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# app/modules/orders/routes/api/admin_exceptions.py
|
||||||
|
"""
|
||||||
|
Admin API endpoints for order item exception management.
|
||||||
|
|
||||||
|
Provides admin-level management of:
|
||||||
|
- Listing exceptions across all vendors
|
||||||
|
- Resolving exceptions by assigning products
|
||||||
|
- Bulk resolution by GTIN
|
||||||
|
- Exception statistics
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, 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.services.order_item_exception_service import order_item_exception_service
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
from app.modules.orders.schemas import (
|
||||||
|
BulkResolveRequest,
|
||||||
|
BulkResolveResponse,
|
||||||
|
IgnoreExceptionRequest,
|
||||||
|
OrderItemExceptionListResponse,
|
||||||
|
OrderItemExceptionResponse,
|
||||||
|
OrderItemExceptionStats,
|
||||||
|
ResolveExceptionRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
admin_exceptions_router = APIRouter(
|
||||||
|
prefix="/order-exceptions",
|
||||||
|
tags=["Order Item Exceptions"],
|
||||||
|
dependencies=[Depends(require_module_access("orders"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Listing and Stats
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_exceptions_router.get("", response_model=OrderItemExceptionListResponse)
|
||||||
|
def list_exceptions(
|
||||||
|
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||||
|
status: str | None = Query(
|
||||||
|
None,
|
||||||
|
pattern="^(pending|resolved|ignored)$",
|
||||||
|
description="Filter by status"
|
||||||
|
),
|
||||||
|
search: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Search in GTIN, product name, SKU, or order number"
|
||||||
|
),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List order item exceptions with filtering and pagination.
|
||||||
|
|
||||||
|
Returns exceptions for unmatched products during marketplace order imports.
|
||||||
|
"""
|
||||||
|
exceptions, total = order_item_exception_service.get_pending_exceptions(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
status=status,
|
||||||
|
search=search,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich with order and vendor info
|
||||||
|
response_items = []
|
||||||
|
for exc in exceptions:
|
||||||
|
item = OrderItemExceptionResponse.model_validate(exc)
|
||||||
|
if exc.order_item and exc.order_item.order:
|
||||||
|
order = exc.order_item.order
|
||||||
|
item.order_number = order.order_number
|
||||||
|
item.order_id = order.id
|
||||||
|
item.order_date = order.order_date
|
||||||
|
item.order_status = order.status
|
||||||
|
# Add vendor name for cross-vendor view
|
||||||
|
if order.vendor:
|
||||||
|
item.vendor_name = order.vendor.name
|
||||||
|
response_items.append(item)
|
||||||
|
|
||||||
|
return OrderItemExceptionListResponse(
|
||||||
|
exceptions=response_items,
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_exceptions_router.get("/stats", response_model=OrderItemExceptionStats)
|
||||||
|
def get_exception_stats(
|
||||||
|
vendor_id: int | None = Query(None, description="Filter by vendor"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get exception statistics.
|
||||||
|
|
||||||
|
Returns counts of pending, resolved, and ignored exceptions.
|
||||||
|
"""
|
||||||
|
stats = order_item_exception_service.get_exception_stats(db, vendor_id)
|
||||||
|
return OrderItemExceptionStats(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Details
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_exceptions_router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
|
||||||
|
def get_exception(
|
||||||
|
exception_id: int = Path(..., description="Exception ID"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get details of a single exception.
|
||||||
|
"""
|
||||||
|
exception = order_item_exception_service.get_exception_by_id(db, exception_id)
|
||||||
|
|
||||||
|
response = OrderItemExceptionResponse.model_validate(exception)
|
||||||
|
if exception.order_item and exception.order_item.order:
|
||||||
|
order = exception.order_item.order
|
||||||
|
response.order_number = order.order_number
|
||||||
|
response.order_id = order.id
|
||||||
|
response.order_date = order.order_date
|
||||||
|
response.order_status = order.status
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Resolution
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_exceptions_router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
|
||||||
|
def resolve_exception(
|
||||||
|
exception_id: int = Path(..., description="Exception ID"),
|
||||||
|
request: ResolveExceptionRequest = ...,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Resolve an exception by assigning a product.
|
||||||
|
|
||||||
|
This updates the order item's product_id and marks the exception as resolved.
|
||||||
|
"""
|
||||||
|
exception = order_item_exception_service.resolve_exception(
|
||||||
|
db=db,
|
||||||
|
exception_id=exception_id,
|
||||||
|
product_id=request.product_id,
|
||||||
|
resolved_by=current_admin.id,
|
||||||
|
notes=request.notes,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrderItemExceptionResponse.model_validate(exception)
|
||||||
|
if exception.order_item and exception.order_item.order:
|
||||||
|
order = exception.order_item.order
|
||||||
|
response.order_number = order.order_number
|
||||||
|
response.order_id = order.id
|
||||||
|
response.order_date = order.order_date
|
||||||
|
response.order_status = order.status
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.id} resolved exception {exception_id} "
|
||||||
|
f"with product {request.product_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@admin_exceptions_router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
|
||||||
|
def ignore_exception(
|
||||||
|
exception_id: int = Path(..., description="Exception ID"),
|
||||||
|
request: IgnoreExceptionRequest = ...,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Mark an exception as ignored.
|
||||||
|
|
||||||
|
Note: Ignored exceptions still block order confirmation.
|
||||||
|
Use this when a product will never be matched (e.g., discontinued).
|
||||||
|
"""
|
||||||
|
exception = order_item_exception_service.ignore_exception(
|
||||||
|
db=db,
|
||||||
|
exception_id=exception_id,
|
||||||
|
resolved_by=current_admin.id,
|
||||||
|
notes=request.notes,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrderItemExceptionResponse.model_validate(exception)
|
||||||
|
if exception.order_item and exception.order_item.order:
|
||||||
|
order = exception.order_item.order
|
||||||
|
response.order_number = order.order_number
|
||||||
|
response.order_id = order.id
|
||||||
|
response.order_date = order.order_date
|
||||||
|
response.order_status = order.status
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.id} ignored exception {exception_id}: {request.notes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Bulk Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_exceptions_router.post("/bulk-resolve", response_model=BulkResolveResponse)
|
||||||
|
def bulk_resolve_by_gtin(
|
||||||
|
request: BulkResolveRequest,
|
||||||
|
vendor_id: int = Query(..., description="Vendor ID"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Bulk resolve all pending exceptions for a GTIN.
|
||||||
|
|
||||||
|
Useful when a new product is imported and multiple orders have
|
||||||
|
items with the same unmatched GTIN.
|
||||||
|
"""
|
||||||
|
resolved_count = order_item_exception_service.bulk_resolve_by_gtin(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
gtin=request.gtin,
|
||||||
|
product_id=request.product_id,
|
||||||
|
resolved_by=current_admin.id,
|
||||||
|
notes=request.notes,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_admin.id} bulk resolved {resolved_count} exceptions "
|
||||||
|
f"for GTIN {request.gtin} with product {request.product_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return BulkResolveResponse(
|
||||||
|
resolved_count=resolved_count,
|
||||||
|
gtin=request.gtin,
|
||||||
|
product_id=request.product_id,
|
||||||
|
)
|
||||||
290
app/modules/orders/routes/api/vendor.py
Normal file
290
app/modules/orders/routes/api/vendor.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# app/modules/orders/routes/api/vendor.py
|
||||||
|
"""
|
||||||
|
Vendor order management endpoints.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
This router aggregates both order routes and exception routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
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.order_inventory_service import order_inventory_service
|
||||||
|
from app.services.order_service import order_service
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
from app.modules.orders.schemas import (
|
||||||
|
OrderDetailResponse,
|
||||||
|
OrderListResponse,
|
||||||
|
OrderResponse,
|
||||||
|
OrderUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base router for orders
|
||||||
|
_orders_router = APIRouter(
|
||||||
|
prefix="/orders",
|
||||||
|
dependencies=[Depends(require_module_access("orders"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate router that includes both orders and exceptions
|
||||||
|
vendor_router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("", response_model=OrderListResponse)
|
||||||
|
def get_vendor_orders(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
status: str | None = Query(None, description="Filter by order status"),
|
||||||
|
customer_id: int | None = Query(None, description="Filter by customer"),
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all orders for vendor.
|
||||||
|
|
||||||
|
Supports filtering by:
|
||||||
|
- status: Order status (pending, processing, shipped, delivered, cancelled)
|
||||||
|
- customer_id: Filter orders from specific customer
|
||||||
|
|
||||||
|
Vendor is determined from JWT token (vendor_id claim).
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
orders, total = order_service.get_vendor_orders(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
status=status,
|
||||||
|
customer_id=customer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrderListResponse(
|
||||||
|
orders=[OrderResponse.model_validate(o) for o in orders],
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||||
|
def get_order_details(
|
||||||
|
order_id: int,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed order information including items and addresses.
|
||||||
|
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
order = order_service.get_order(
|
||||||
|
db=db, vendor_id=current_user.token_vendor_id, order_id=order_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrderDetailResponse.model_validate(order)
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.put("/{order_id}/status", response_model=OrderResponse)
|
||||||
|
def update_order_status(
|
||||||
|
order_id: int,
|
||||||
|
order_update: OrderUpdate,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update order status and tracking information.
|
||||||
|
|
||||||
|
Valid statuses:
|
||||||
|
- pending: Order placed, awaiting processing
|
||||||
|
- processing: Order being prepared
|
||||||
|
- shipped: Order shipped to customer
|
||||||
|
- delivered: Order delivered
|
||||||
|
- cancelled: Order cancelled
|
||||||
|
- refunded: Order refunded
|
||||||
|
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
order = order_service.update_order_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
order_update=order_update,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Order {order.order_number} status updated to {order.status} "
|
||||||
|
f"by user {current_user.username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrderResponse.model_validate(order)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Partial Shipment Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ShipItemRequest(BaseModel):
|
||||||
|
"""Request to ship specific quantity of an order item."""
|
||||||
|
|
||||||
|
quantity: int | None = Field(
|
||||||
|
None, ge=1, description="Quantity to ship (default: remaining quantity)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShipItemResponse(BaseModel):
|
||||||
|
"""Response from shipping an item."""
|
||||||
|
|
||||||
|
order_id: int
|
||||||
|
item_id: int
|
||||||
|
fulfilled_quantity: int
|
||||||
|
shipped_quantity: int | None = None
|
||||||
|
remaining_quantity: int | None = None
|
||||||
|
is_fully_shipped: bool | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentStatusItemResponse(BaseModel):
|
||||||
|
"""Item-level shipment status."""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
product_id: int
|
||||||
|
product_name: str
|
||||||
|
quantity: int
|
||||||
|
shipped_quantity: int
|
||||||
|
remaining_quantity: int
|
||||||
|
is_fully_shipped: bool
|
||||||
|
is_partially_shipped: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ShipmentStatusResponse(BaseModel):
|
||||||
|
"""Order shipment status response."""
|
||||||
|
|
||||||
|
order_id: int
|
||||||
|
order_number: str
|
||||||
|
order_status: str
|
||||||
|
is_fully_shipped: bool
|
||||||
|
is_partially_shipped: bool
|
||||||
|
shipped_item_count: int
|
||||||
|
total_item_count: int
|
||||||
|
total_shipped_units: int
|
||||||
|
total_ordered_units: int
|
||||||
|
items: list[ShipmentStatusItemResponse]
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.get("/{order_id}/shipment-status", response_model=ShipmentStatusResponse)
|
||||||
|
def get_shipment_status(
|
||||||
|
order_id: int,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed shipment status for an order.
|
||||||
|
|
||||||
|
Returns item-level shipment status showing what has been shipped
|
||||||
|
and what remains. Useful for partial shipment tracking.
|
||||||
|
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
result = order_inventory_service.get_shipment_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ShipmentStatusResponse(
|
||||||
|
order_id=result["order_id"],
|
||||||
|
order_number=result["order_number"],
|
||||||
|
order_status=result["order_status"],
|
||||||
|
is_fully_shipped=result["is_fully_shipped"],
|
||||||
|
is_partially_shipped=result["is_partially_shipped"],
|
||||||
|
shipped_item_count=result["shipped_item_count"],
|
||||||
|
total_item_count=result["total_item_count"],
|
||||||
|
total_shipped_units=result["total_shipped_units"],
|
||||||
|
total_ordered_units=result["total_ordered_units"],
|
||||||
|
items=[ShipmentStatusItemResponse(**item) for item in result["items"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_orders_router.post("/{order_id}/items/{item_id}/ship", response_model=ShipItemResponse)
|
||||||
|
def ship_order_item(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
request: ShipItemRequest | None = None,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ship a specific order item (supports partial shipment).
|
||||||
|
|
||||||
|
Fulfills inventory and updates the item's shipped quantity.
|
||||||
|
If quantity is not specified, ships the remaining quantity.
|
||||||
|
|
||||||
|
Example use cases:
|
||||||
|
- Ship all of an item: POST /orders/{id}/items/{item_id}/ship
|
||||||
|
- Ship partial: POST /orders/{id}/items/{item_id}/ship with {"quantity": 2}
|
||||||
|
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
quantity = request.quantity if request else None
|
||||||
|
|
||||||
|
result = order_inventory_service.fulfill_item(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity,
|
||||||
|
skip_missing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update order status based on shipment state
|
||||||
|
order = order_service.get_order(db, current_user.token_vendor_id, order_id)
|
||||||
|
|
||||||
|
if order.is_fully_shipped and order.status != "shipped":
|
||||||
|
order_service.update_order_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
order_update=OrderUpdate(status="shipped"),
|
||||||
|
)
|
||||||
|
logger.info(f"Order {order.order_number} fully shipped")
|
||||||
|
elif order.is_partially_shipped and order.status not in (
|
||||||
|
"partially_shipped",
|
||||||
|
"shipped",
|
||||||
|
):
|
||||||
|
order_service.update_order_status(
|
||||||
|
db=db,
|
||||||
|
vendor_id=current_user.token_vendor_id,
|
||||||
|
order_id=order_id,
|
||||||
|
order_update=OrderUpdate(status="partially_shipped"),
|
||||||
|
)
|
||||||
|
logger.info(f"Order {order.order_number} partially shipped")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Shipped item {item_id} of order {order_id}: "
|
||||||
|
f"{result.get('fulfilled_quantity', 0)} units"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ShipItemResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Aggregate routers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Import exceptions router
|
||||||
|
from app.modules.orders.routes.api.vendor_exceptions import vendor_exceptions_router
|
||||||
|
|
||||||
|
# Include both routers into the aggregate vendor_router
|
||||||
|
vendor_router.include_router(_orders_router, tags=["vendor-orders"])
|
||||||
|
vendor_router.include_router(vendor_exceptions_router, tags=["vendor-order-exceptions"])
|
||||||
265
app/modules/orders/routes/api/vendor_exceptions.py
Normal file
265
app/modules/orders/routes/api/vendor_exceptions.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# app/modules/orders/routes/api/vendor_exceptions.py
|
||||||
|
"""
|
||||||
|
Vendor API endpoints for order item exception management.
|
||||||
|
|
||||||
|
Provides vendor-level management of:
|
||||||
|
- Listing vendor's own exceptions
|
||||||
|
- Resolving exceptions by assigning products
|
||||||
|
- Exception statistics for vendor dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.services.order_item_exception_service import order_item_exception_service
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
from app.modules.orders.schemas import (
|
||||||
|
BulkResolveRequest,
|
||||||
|
BulkResolveResponse,
|
||||||
|
IgnoreExceptionRequest,
|
||||||
|
OrderItemExceptionListResponse,
|
||||||
|
OrderItemExceptionResponse,
|
||||||
|
OrderItemExceptionStats,
|
||||||
|
ResolveExceptionRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
vendor_exceptions_router = APIRouter(
|
||||||
|
prefix="/order-exceptions",
|
||||||
|
tags=["Vendor Order Item Exceptions"],
|
||||||
|
dependencies=[Depends(require_module_access("orders"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Listing and Stats
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_exceptions_router.get("", response_model=OrderItemExceptionListResponse)
|
||||||
|
def list_vendor_exceptions(
|
||||||
|
status: str | None = Query(
|
||||||
|
None,
|
||||||
|
pattern="^(pending|resolved|ignored)$",
|
||||||
|
description="Filter by status"
|
||||||
|
),
|
||||||
|
search: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Search in GTIN, product name, SKU, or order number"
|
||||||
|
),
|
||||||
|
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 order item exceptions for the authenticated vendor.
|
||||||
|
|
||||||
|
Returns exceptions for unmatched products during marketplace order imports.
|
||||||
|
"""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
exceptions, total = order_item_exception_service.get_pending_exceptions(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
status=status,
|
||||||
|
search=search,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich with order info
|
||||||
|
response_items = []
|
||||||
|
for exc in exceptions:
|
||||||
|
item = OrderItemExceptionResponse.model_validate(exc)
|
||||||
|
if exc.order_item and exc.order_item.order:
|
||||||
|
order = exc.order_item.order
|
||||||
|
item.order_number = order.order_number
|
||||||
|
item.order_id = order.id
|
||||||
|
item.order_date = order.order_date
|
||||||
|
item.order_status = order.status
|
||||||
|
response_items.append(item)
|
||||||
|
|
||||||
|
return OrderItemExceptionListResponse(
|
||||||
|
exceptions=response_items,
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_exceptions_router.get("/stats", response_model=OrderItemExceptionStats)
|
||||||
|
def get_vendor_exception_stats(
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get exception statistics for the authenticated vendor.
|
||||||
|
|
||||||
|
Returns counts of pending, resolved, and ignored exceptions.
|
||||||
|
"""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
stats = order_item_exception_service.get_exception_stats(db, vendor_id)
|
||||||
|
return OrderItemExceptionStats(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Details
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_exceptions_router.get("/{exception_id}", response_model=OrderItemExceptionResponse)
|
||||||
|
def get_vendor_exception(
|
||||||
|
exception_id: int = Path(..., description="Exception ID"),
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get details of a single exception (vendor-scoped).
|
||||||
|
"""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
# Pass vendor_id for scoped access
|
||||||
|
exception = order_item_exception_service.get_exception_by_id(
|
||||||
|
db, exception_id, vendor_id
|
||||||
|
)
|
||||||
|
|
||||||
|
response = OrderItemExceptionResponse.model_validate(exception)
|
||||||
|
if exception.order_item and exception.order_item.order:
|
||||||
|
order = exception.order_item.order
|
||||||
|
response.order_number = order.order_number
|
||||||
|
response.order_id = order.id
|
||||||
|
response.order_date = order.order_date
|
||||||
|
response.order_status = order.status
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Resolution
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_exceptions_router.post("/{exception_id}/resolve", response_model=OrderItemExceptionResponse)
|
||||||
|
def resolve_vendor_exception(
|
||||||
|
exception_id: int = Path(..., description="Exception ID"),
|
||||||
|
request: ResolveExceptionRequest = ...,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Resolve an exception by assigning a product (vendor-scoped).
|
||||||
|
|
||||||
|
This updates the order item's product_id and marks the exception as resolved.
|
||||||
|
"""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
exception = order_item_exception_service.resolve_exception(
|
||||||
|
db=db,
|
||||||
|
exception_id=exception_id,
|
||||||
|
product_id=request.product_id,
|
||||||
|
resolved_by=current_user.id,
|
||||||
|
notes=request.notes,
|
||||||
|
vendor_id=vendor_id, # Vendor-scoped access
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrderItemExceptionResponse.model_validate(exception)
|
||||||
|
if exception.order_item and exception.order_item.order:
|
||||||
|
order = exception.order_item.order
|
||||||
|
response.order_number = order.order_number
|
||||||
|
response.order_id = order.id
|
||||||
|
response.order_date = order.order_date
|
||||||
|
response.order_status = order.status
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Vendor user {current_user.id} resolved exception {exception_id} "
|
||||||
|
f"with product {request.product_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_exceptions_router.post("/{exception_id}/ignore", response_model=OrderItemExceptionResponse)
|
||||||
|
def ignore_vendor_exception(
|
||||||
|
exception_id: int = Path(..., description="Exception ID"),
|
||||||
|
request: IgnoreExceptionRequest = ...,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Mark an exception as ignored (vendor-scoped).
|
||||||
|
|
||||||
|
Note: Ignored exceptions still block order confirmation.
|
||||||
|
Use this when a product will never be matched (e.g., discontinued).
|
||||||
|
"""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
exception = order_item_exception_service.ignore_exception(
|
||||||
|
db=db,
|
||||||
|
exception_id=exception_id,
|
||||||
|
resolved_by=current_user.id,
|
||||||
|
notes=request.notes,
|
||||||
|
vendor_id=vendor_id, # Vendor-scoped access
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrderItemExceptionResponse.model_validate(exception)
|
||||||
|
if exception.order_item and exception.order_item.order:
|
||||||
|
order = exception.order_item.order
|
||||||
|
response.order_number = order.order_number
|
||||||
|
response.order_id = order.id
|
||||||
|
response.order_date = order.order_date
|
||||||
|
response.order_status = order.status
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Vendor user {current_user.id} ignored exception {exception_id}: {request.notes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Bulk Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_exceptions_router.post("/bulk-resolve", response_model=BulkResolveResponse)
|
||||||
|
def bulk_resolve_vendor_exceptions(
|
||||||
|
request: BulkResolveRequest,
|
||||||
|
current_user: UserContext = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Bulk resolve all pending exceptions for a GTIN (vendor-scoped).
|
||||||
|
|
||||||
|
Useful when a new product is imported and multiple orders have
|
||||||
|
items with the same unmatched GTIN.
|
||||||
|
"""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
resolved_count = order_item_exception_service.bulk_resolve_by_gtin(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
gtin=request.gtin,
|
||||||
|
product_id=request.product_id,
|
||||||
|
resolved_by=current_user.id,
|
||||||
|
notes=request.notes,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Vendor user {current_user.id} bulk resolved {resolved_count} exceptions "
|
||||||
|
f"for GTIN {request.gtin} with product {request.product_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return BulkResolveResponse(
|
||||||
|
resolved_count=resolved_count,
|
||||||
|
gtin=request.gtin,
|
||||||
|
product_id=request.product_id,
|
||||||
|
)
|
||||||
@@ -22,11 +22,20 @@ Usage:
|
|||||||
tags=route_info["tags"],
|
tags=route_info["tags"],
|
||||||
include_in_schema=route_info.get("include_in_schema", True),
|
include_in_schema=route_info.get("include_in_schema", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Route Configuration:
|
||||||
|
Modules can export a ROUTE_CONFIG dict to customize route registration:
|
||||||
|
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "/content-pages", # Custom prefix (replaces default)
|
||||||
|
"tags": ["admin-content-pages"], # Custom tags
|
||||||
|
"priority": 100, # Higher = registered later (for catch-all routes)
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -47,6 +56,8 @@ class RouteInfo:
|
|||||||
module_code: str = ""
|
module_code: str = ""
|
||||||
route_type: str = "" # "api" or "pages"
|
route_type: str = "" # "api" or "pages"
|
||||||
frontend: str = "" # "admin", "vendor", "shop"
|
frontend: str = "" # "admin", "vendor", "shop"
|
||||||
|
priority: int = 0 # Higher = registered later (for catch-all routes)
|
||||||
|
custom_prefix: str = "" # Custom prefix from ROUTE_CONFIG
|
||||||
|
|
||||||
|
|
||||||
def discover_module_routes() -> list[RouteInfo]:
|
def discover_module_routes() -> list[RouteInfo]:
|
||||||
@@ -113,9 +124,10 @@ def _discover_module_routes(module_code: str) -> list[RouteInfo]:
|
|||||||
if not routes_path.exists():
|
if not routes_path.exists():
|
||||||
return routes
|
return routes
|
||||||
|
|
||||||
# Discover API routes
|
# Discover API routes (with fallback to routes/ for legacy modules)
|
||||||
api_routes = _discover_routes_in_dir(
|
api_routes = _discover_routes_in_dir(
|
||||||
module_code, dir_name, routes_path / "api", "api"
|
module_code, dir_name, routes_path / "api", "api",
|
||||||
|
fallback_dir=routes_path # Allow routes/admin.py as fallback
|
||||||
)
|
)
|
||||||
routes.extend(api_routes)
|
routes.extend(api_routes)
|
||||||
|
|
||||||
@@ -129,7 +141,8 @@ def _discover_module_routes(module_code: str) -> list[RouteInfo]:
|
|||||||
|
|
||||||
|
|
||||||
def _discover_routes_in_dir(
|
def _discover_routes_in_dir(
|
||||||
module_code: str, dir_name: str, routes_dir: Path, route_type: str
|
module_code: str, dir_name: str, routes_dir: Path, route_type: str,
|
||||||
|
fallback_dir: Path | None = None
|
||||||
) -> list[RouteInfo]:
|
) -> list[RouteInfo]:
|
||||||
"""
|
"""
|
||||||
Discover routes in a specific directory (api/ or pages/).
|
Discover routes in a specific directory (api/ or pages/).
|
||||||
@@ -139,15 +152,14 @@ def _discover_routes_in_dir(
|
|||||||
dir_name: Directory name (module_code with _ instead of -)
|
dir_name: Directory name (module_code with _ instead of -)
|
||||||
routes_dir: Path to routes/api/ or routes/pages/
|
routes_dir: Path to routes/api/ or routes/pages/
|
||||||
route_type: "api" or "pages"
|
route_type: "api" or "pages"
|
||||||
|
fallback_dir: Optional fallback directory (e.g., routes/ for modules
|
||||||
|
with routes/admin.py instead of routes/api/admin.py)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of RouteInfo for discovered routes
|
List of RouteInfo for discovered routes
|
||||||
"""
|
"""
|
||||||
routes: list[RouteInfo] = []
|
routes: list[RouteInfo] = []
|
||||||
|
|
||||||
if not routes_dir.exists():
|
|
||||||
return routes
|
|
||||||
|
|
||||||
# Look for admin.py, vendor.py, shop.py
|
# Look for admin.py, vendor.py, shop.py
|
||||||
frontends = {
|
frontends = {
|
||||||
"admin": {
|
"admin": {
|
||||||
@@ -168,13 +180,26 @@ def _discover_routes_in_dir(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for frontend, config in frontends.items():
|
for frontend, config in frontends.items():
|
||||||
route_file = routes_dir / f"{frontend}.py"
|
# Check primary location first, then fallback
|
||||||
if not route_file.exists():
|
route_file = routes_dir / f"{frontend}.py" if routes_dir.exists() else None
|
||||||
|
use_fallback = False
|
||||||
|
|
||||||
|
if route_file is None or not route_file.exists():
|
||||||
|
if fallback_dir and fallback_dir.exists():
|
||||||
|
fallback_file = fallback_dir / f"{frontend}.py"
|
||||||
|
if fallback_file.exists():
|
||||||
|
route_file = fallback_file
|
||||||
|
use_fallback = True
|
||||||
|
|
||||||
|
if route_file is None or not route_file.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Import the module
|
# Import the module
|
||||||
import_path = f"app.modules.{dir_name}.routes.{route_type}.{frontend}"
|
if use_fallback:
|
||||||
|
import_path = f"app.modules.{dir_name}.routes.{frontend}"
|
||||||
|
else:
|
||||||
|
import_path = f"app.modules.{dir_name}.routes.{route_type}.{frontend}"
|
||||||
route_module = importlib.import_module(import_path)
|
route_module = importlib.import_module(import_path)
|
||||||
|
|
||||||
# Get the router (try common names)
|
# Get the router (try common names)
|
||||||
@@ -188,14 +213,23 @@ def _discover_routes_in_dir(
|
|||||||
logger.warning(f"No router found in {import_path}")
|
logger.warning(f"No router found in {import_path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Read ROUTE_CONFIG if present
|
||||||
|
route_config = getattr(route_module, "ROUTE_CONFIG", {})
|
||||||
|
custom_prefix = route_config.get("prefix", "")
|
||||||
|
custom_tags = route_config.get("tags", [])
|
||||||
|
priority = route_config.get("priority", 0)
|
||||||
|
|
||||||
# Determine prefix based on route type
|
# Determine prefix based on route type
|
||||||
if route_type == "api":
|
if route_type == "api":
|
||||||
prefix = config["api_prefix"]
|
prefix = config["api_prefix"]
|
||||||
else:
|
else:
|
||||||
prefix = config["pages_prefix"]
|
prefix = config["pages_prefix"]
|
||||||
|
|
||||||
# Build tags
|
# Build tags - use custom tags if provided, otherwise default
|
||||||
tags = [f"{module_code}-{frontend}-{route_type}"]
|
if custom_tags:
|
||||||
|
tags = custom_tags
|
||||||
|
else:
|
||||||
|
tags = [f"{frontend}-{module_code}"]
|
||||||
|
|
||||||
route_info = RouteInfo(
|
route_info = RouteInfo(
|
||||||
router=router,
|
router=router,
|
||||||
@@ -205,6 +239,8 @@ def _discover_routes_in_dir(
|
|||||||
module_code=module_code,
|
module_code=module_code,
|
||||||
route_type=route_type,
|
route_type=route_type,
|
||||||
frontend=frontend,
|
frontend=frontend,
|
||||||
|
priority=priority,
|
||||||
|
custom_prefix=custom_prefix,
|
||||||
)
|
)
|
||||||
routes.append(route_info)
|
routes.append(route_info)
|
||||||
|
|
||||||
@@ -238,6 +274,36 @@ def get_admin_page_routes() -> list[RouteInfo]:
|
|||||||
return [r for r in discover_module_routes() if r.route_type == "pages" and r.frontend == "admin"]
|
return [r for r in discover_module_routes() if r.route_type == "pages" and r.frontend == "admin"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_api_routes() -> list[RouteInfo]:
|
||||||
|
"""
|
||||||
|
Get admin API routes from modules, sorted by priority.
|
||||||
|
|
||||||
|
Returns routes sorted by priority (lower first, higher last).
|
||||||
|
This ensures catch-all routes (priority 100+) are registered after
|
||||||
|
specific routes.
|
||||||
|
"""
|
||||||
|
routes = [
|
||||||
|
r for r in discover_module_routes()
|
||||||
|
if r.route_type == "api" and r.frontend == "admin"
|
||||||
|
]
|
||||||
|
return sorted(routes, key=lambda r: r.priority)
|
||||||
|
|
||||||
|
|
||||||
|
def get_vendor_api_routes() -> list[RouteInfo]:
|
||||||
|
"""
|
||||||
|
Get vendor API routes from modules, sorted by priority.
|
||||||
|
|
||||||
|
Returns routes sorted by priority (lower first, higher last).
|
||||||
|
This ensures catch-all routes (priority 100+) are registered after
|
||||||
|
specific routes.
|
||||||
|
"""
|
||||||
|
routes = [
|
||||||
|
r for r in discover_module_routes()
|
||||||
|
if r.route_type == "api" and r.frontend == "vendor"
|
||||||
|
]
|
||||||
|
return sorted(routes, key=lambda r: r.priority)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RouteInfo",
|
"RouteInfo",
|
||||||
"discover_module_routes",
|
"discover_module_routes",
|
||||||
@@ -245,4 +311,6 @@ __all__ = [
|
|||||||
"get_page_routes",
|
"get_page_routes",
|
||||||
"get_vendor_page_routes",
|
"get_vendor_page_routes",
|
||||||
"get_admin_page_routes",
|
"get_admin_page_routes",
|
||||||
|
"get_admin_api_routes",
|
||||||
|
"get_vendor_api_routes",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user