diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 0cf40d08..abb7a651 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -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 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 - inventory: Stock management, inventory tracking - orders: Order management, fulfillment, exceptions - marketplace: Letzshop integration, product sync - -Module extraction pattern: -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 +- cms: Content pages management +- customers: Customer management """ from fastapi import APIRouter -# Import all admin routers +# Import all admin routers (legacy routes that haven't been migrated to modules) from . import ( admin_users, audit, @@ -48,16 +44,11 @@ from . import ( background_tasks, code_quality, companies, - # content_pages - moved to app.modules.cms.routes.api.admin - # customers - moved to app.modules.customers.routes.admin dashboard, email_templates, features, images, - inventory, - letzshop, logs, - marketplace, media, menu_config, messages, @@ -65,8 +56,6 @@ from . import ( modules, monitoring, notifications, - order_item_exceptions, - orders, platform_health, platforms, products, @@ -80,21 +69,6 @@ from . import ( 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 router = APIRouter() @@ -123,12 +97,6 @@ router.include_router(vendor_domains.router, tags=["admin-vendor-domains"]) # Include vendor themes management endpoints 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) 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) 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 @@ -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 @@ -175,27 +139,6 @@ router.include_router(products.router, tags=["admin-marketplace-products"]) # Include vendor product catalog management endpoints 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 @@ -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 # ============================================================================ @@ -263,5 +191,31 @@ router.include_router( # Include test runner endpoints 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 __all__ = ["router"] diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index baad0c40..96a6fadb 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -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 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 - inventory: Stock management, inventory tracking - orders: Order management, fulfillment, exceptions - marketplace: Letzshop integration, product sync - -Module extraction pattern: -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 +- cms: Content pages management +- customers: Customer management """ 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 ( analytics, auth, billing, - # content_pages - moved to app.modules.cms.routes.api.vendor - # customers - moved to app.modules.customers.routes.vendor dashboard, email_settings, email_templates, features, info, - inventory, invoices, - letzshop, - marketplace, media, messages, notifications, onboarding, - order_item_exceptions, - orders, payments, products, profile, @@ -59,21 +48,6 @@ from . import ( 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 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(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"]) - -# 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"]) - -# 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"]) -# 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.) router.include_router(payments.router, tags=["vendor-payments"]) router.include_router(media.router, tags=["vendor-media"]) router.include_router(notifications.router, tags=["vendor-notifications"]) router.include_router(messages.router, tags=["vendor-messages"]) 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(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} router.include_router(info.router, tags=["vendor-info"]) diff --git a/app/modules/analytics/routes/api/vendor.py b/app/modules/analytics/routes/api/vendor.py index 0d8e6c4a..5fa88131 100644 --- a/app/modules/analytics/routes/api/vendor.py +++ b/app/modules/analytics/routes/api/vendor.py @@ -28,8 +28,10 @@ from app.modules.billing.models import FeatureCode from models.database.user import User router = APIRouter( + prefix="/analytics", dependencies=[Depends(require_module_access("analytics"))], ) +vendor_router = router # Alias for discovery logger = logging.getLogger(__name__) diff --git a/app/modules/cms/routes/api/admin.py b/app/modules/cms/routes/api/admin.py index 8f2c9215..e0b9b36e 100644 --- a/app/modules/cms/routes/api/admin.py +++ b/app/modules/cms/routes/api/admin.py @@ -25,7 +25,15 @@ from app.modules.cms.schemas import ( from app.modules.cms.services import content_page_service 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() +admin_router = router # Alias for discovery compatibility logger = logging.getLogger(__name__) diff --git a/app/modules/cms/routes/api/vendor.py b/app/modules/cms/routes/api/vendor.py index 084fe40d..2d3113f3 100644 --- a/app/modules/cms/routes/api/vendor.py +++ b/app/modules/cms/routes/api/vendor.py @@ -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 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() router = APIRouter() +vendor_router = router # Alias for discovery compatibility logger = logging.getLogger(__name__) diff --git a/app/modules/customers/routes/api/__init__.py b/app/modules/customers/routes/api/__init__.py index def9c0c7..afbe1f6f 100644 --- a/app/modules/customers/routes/api/__init__.py +++ b/app/modules/customers/routes/api/__init__.py @@ -6,4 +6,20 @@ from app.modules.customers.routes.api.storefront import router as storefront_rou # Tag for OpenAPI documentation 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}") diff --git a/app/modules/customers/routes/admin.py b/app/modules/customers/routes/api/admin.py similarity index 98% rename from app/modules/customers/routes/admin.py rename to app/modules/customers/routes/api/admin.py index 685dc967..f5093013 100644 --- a/app/modules/customers/routes/admin.py +++ b/app/modules/customers/routes/api/admin.py @@ -1,4 +1,4 @@ -# app/modules/customers/routes/admin.py +# app/modules/customers/routes/api/admin.py """ Customer management endpoints for admin. diff --git a/app/modules/customers/routes/vendor.py b/app/modules/customers/routes/api/vendor.py similarity index 99% rename from app/modules/customers/routes/vendor.py rename to app/modules/customers/routes/api/vendor.py index f6d0d30c..e738ae47 100644 --- a/app/modules/customers/routes/vendor.py +++ b/app/modules/customers/routes/api/vendor.py @@ -1,4 +1,4 @@ -# app/modules/customers/routes/vendor.py +# app/modules/customers/routes/api/vendor.py """ Vendor customer management endpoints. diff --git a/app/modules/orders/routes/api/__init__.py b/app/modules/orders/routes/api/__init__.py index d9a51eb2..7c049a4c 100644 --- a/app/modules/orders/routes/api/__init__.py +++ b/app/modules/orders/routes/api/__init__.py @@ -1,9 +1,35 @@ # 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 # Tag for OpenAPI documentation 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}") diff --git a/app/modules/orders/routes/api/admin.py b/app/modules/orders/routes/api/admin.py new file mode 100644 index 00000000..22ef6805 --- /dev/null +++ b/app/modules/orders/routes/api/admin.py @@ -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"]) diff --git a/app/modules/orders/routes/api/admin_exceptions.py b/app/modules/orders/routes/api/admin_exceptions.py new file mode 100644 index 00000000..62fdc57a --- /dev/null +++ b/app/modules/orders/routes/api/admin_exceptions.py @@ -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, + ) diff --git a/app/modules/orders/routes/api/vendor.py b/app/modules/orders/routes/api/vendor.py new file mode 100644 index 00000000..ffa5fbd8 --- /dev/null +++ b/app/modules/orders/routes/api/vendor.py @@ -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"]) diff --git a/app/modules/orders/routes/api/vendor_exceptions.py b/app/modules/orders/routes/api/vendor_exceptions.py new file mode 100644 index 00000000..a95429fe --- /dev/null +++ b/app/modules/orders/routes/api/vendor_exceptions.py @@ -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, + ) diff --git a/app/modules/routes.py b/app/modules/routes.py index f8ad8ea8..936aae6e 100644 --- a/app/modules/routes.py +++ b/app/modules/routes.py @@ -22,11 +22,20 @@ Usage: tags=route_info["tags"], 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 logging -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING @@ -47,6 +56,8 @@ class RouteInfo: module_code: str = "" route_type: str = "" # "api" or "pages" 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]: @@ -113,9 +124,10 @@ def _discover_module_routes(module_code: str) -> list[RouteInfo]: if not routes_path.exists(): return routes - # Discover API routes + # Discover API routes (with fallback to routes/ for legacy modules) 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) @@ -129,7 +141,8 @@ def _discover_module_routes(module_code: str) -> list[RouteInfo]: 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]: """ 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 -) routes_dir: Path to routes/api/ or routes/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: List of RouteInfo for discovered routes """ routes: list[RouteInfo] = [] - if not routes_dir.exists(): - return routes - # Look for admin.py, vendor.py, shop.py frontends = { "admin": { @@ -168,13 +180,26 @@ def _discover_routes_in_dir( } for frontend, config in frontends.items(): - route_file = routes_dir / f"{frontend}.py" - if not route_file.exists(): + # Check primary location first, then fallback + 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 try: # 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) # Get the router (try common names) @@ -188,14 +213,23 @@ def _discover_routes_in_dir( logger.warning(f"No router found in {import_path}") 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 if route_type == "api": prefix = config["api_prefix"] else: prefix = config["pages_prefix"] - # Build tags - tags = [f"{module_code}-{frontend}-{route_type}"] + # Build tags - use custom tags if provided, otherwise default + if custom_tags: + tags = custom_tags + else: + tags = [f"{frontend}-{module_code}"] route_info = RouteInfo( router=router, @@ -205,6 +239,8 @@ def _discover_routes_in_dir( module_code=module_code, route_type=route_type, frontend=frontend, + priority=priority, + custom_prefix=custom_prefix, ) 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"] +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__ = [ "RouteInfo", "discover_module_routes", @@ -245,4 +311,6 @@ __all__ = [ "get_page_routes", "get_vendor_page_routes", "get_admin_page_routes", + "get_admin_api_routes", + "get_vendor_api_routes", ]