refactor: migrate products and vendor_products to module auto-discovery

- Move admin/products.py to marketplace module as admin_products.py
  (marketplace product catalog browsing)
- Move admin/vendor_products.py to catalog module as admin.py
  (vendor catalog management)
- Move vendor/products.py to catalog module as vendor.py
  (vendor's own product catalog)
- Update marketplace admin router to include products routes
- Update catalog module routes/api/__init__.py with lazy imports
- Remove legacy imports from admin and vendor API init files

All product routes now auto-discovered via module system.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 12:49:11 +01:00
parent db56b34894
commit 6f278131a3
7 changed files with 92 additions and 90 deletions

View File

@@ -29,7 +29,8 @@ Self-contained modules (auto-discovered from app/modules/{module}/routes/api/adm
- billing: Subscription tiers, vendor billing, invoices
- inventory: Stock management, inventory tracking
- orders: Order management, fulfillment, exceptions
- marketplace: Letzshop integration, product sync
- marketplace: Letzshop integration, product sync, marketplace products
- catalog: Vendor product catalog management
- cms: Content pages management
- customers: Customer management
"""
@@ -58,13 +59,11 @@ from . import (
notifications,
platform_health,
platforms,
products,
settings,
subscriptions, # Legacy - will be replaced by billing module router
tests,
users,
vendor_domains,
vendor_products,
vendor_themes,
vendors,
)
@@ -129,17 +128,6 @@ router.include_router(admin_users.router, tags=["admin-admin-users"])
router.include_router(dashboard.router, tags=["admin-dashboard"])
# ============================================================================
# Vendor Operations (Product Catalog)
# ============================================================================
# Include marketplace product catalog management endpoints
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"])
# ============================================================================
# Platform Administration
# ============================================================================

View File

@@ -19,6 +19,7 @@ Self-contained modules (auto-discovered from app/modules/{module}/routes/api/ven
- inventory: Stock management, inventory tracking
- orders: Order management, fulfillment, exceptions
- marketplace: Letzshop integration, product sync
- catalog: Vendor product catalog management
- cms: Content pages management
- customers: Customer management
"""
@@ -41,7 +42,6 @@ from . import (
notifications,
onboarding,
payments,
products,
profile,
settings,
team,
@@ -71,8 +71,7 @@ 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/*, etc.)
router.include_router(products.router, tags=["vendor-products"])
# Business operations (with prefixes: /invoices/*, /team/*)
router.include_router(invoices.router, tags=["vendor-invoices"])
router.include_router(team.router, tags=["vendor-team"])

View File

@@ -6,4 +6,20 @@ from app.modules.catalog.routes.api.storefront import router as storefront_route
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Catalog (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.catalog.routes.api.admin import admin_router
return admin_router
elif name == "vendor_router":
from app.modules.catalog.routes.api.vendor import vendor_router
return vendor_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,15 +1,13 @@
# app/api/v1/admin/vendor_products.py
# app/modules/catalog/routes/api/admin.py
"""
Admin vendor product catalog endpoints.
Provides management of vendor-specific product catalogs:
- Browse products in vendor catalogs
- View product details with override info
- Remove products from catalog
- Create/update/remove products from catalog
Architecture Notes:
- All Pydantic schemas are defined in models/schema/vendor_product.py
- Business logic is delegated to vendor_product_service
All routes require module access control for the 'catalog' module.
"""
import logging
@@ -17,7 +15,7 @@ import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.services.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
@@ -35,7 +33,10 @@ from app.modules.catalog.schemas import (
VendorProductUpdate,
)
router = APIRouter(prefix="/vendor-products")
admin_router = APIRouter(
prefix="/vendor-products",
dependencies=[Depends(require_module_access("catalog"))],
)
logger = logging.getLogger(__name__)
@@ -44,7 +45,7 @@ logger = logging.getLogger(__name__)
# ============================================================================
@router.get("", response_model=VendorProductListResponse)
@admin_router.get("", response_model=VendorProductListResponse)
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
@@ -81,7 +82,7 @@ def get_vendor_products(
)
@router.get("/stats", response_model=VendorProductStats)
@admin_router.get("/stats", response_model=VendorProductStats)
def get_vendor_product_stats(
vendor_id: int | None = Query(None, description="Filter stats by vendor ID"),
db: Session = Depends(get_db),
@@ -92,7 +93,7 @@ def get_vendor_product_stats(
return VendorProductStats(**stats)
@router.get("/vendors", response_model=CatalogVendorsResponse)
@admin_router.get("/vendors", response_model=CatalogVendorsResponse)
def get_catalog_vendors(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
@@ -102,7 +103,7 @@ def get_catalog_vendors(
return CatalogVendorsResponse(vendors=[CatalogVendor(**v) for v in vendors])
@router.get("/{product_id}", response_model=VendorProductDetail)
@admin_router.get("/{product_id}", response_model=VendorProductDetail)
def get_vendor_product_detail(
product_id: int,
db: Session = Depends(get_db),
@@ -113,7 +114,7 @@ def get_vendor_product_detail(
return VendorProductDetail(**product)
@router.post("", response_model=VendorProductCreateResponse)
@admin_router.post("", response_model=VendorProductCreateResponse)
def create_vendor_product(
data: VendorProductCreate,
db: Session = Depends(get_db),
@@ -124,13 +125,13 @@ def create_vendor_product(
subscription_service.check_product_limit(db, data.vendor_id)
product = vendor_product_service.create_product(db, data.model_dump())
db.commit() # ✅ ARCH: Commit at API level for transaction control
db.commit()
return VendorProductCreateResponse(
id=product.id, message="Product created successfully"
)
@router.patch("/{product_id}", response_model=VendorProductDetail)
@admin_router.patch("/{product_id}", response_model=VendorProductDetail)
def update_vendor_product(
product_id: int,
data: VendorProductUpdate,
@@ -141,13 +142,13 @@ def update_vendor_product(
# Only include fields that were explicitly set
update_data = data.model_dump(exclude_unset=True)
vendor_product_service.update_product(db, product_id, update_data)
db.commit() # ✅ ARCH: Commit at API level for transaction control
db.commit()
# Return the updated product detail
product = vendor_product_service.get_product_detail(db, product_id)
return VendorProductDetail(**product)
@router.delete("/{product_id}", response_model=RemoveProductResponse)
@admin_router.delete("/{product_id}", response_model=RemoveProductResponse)
def remove_vendor_product(
product_id: int,
db: Session = Depends(get_db),
@@ -155,5 +156,5 @@ def remove_vendor_product(
):
"""Remove a product from vendor catalog."""
result = vendor_product_service.remove_product(db, product_id)
db.commit() # ✅ ARCH: Commit at API level for transaction control
db.commit()
return RemoveProductResponse(**result)

View File

@@ -1,9 +1,11 @@
# app/api/v1/vendor/products.py
# app/modules/catalog/routes/api/vendor.py
"""
Vendor product catalog 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.
All routes require module access control for the 'catalog' module.
"""
import logging
@@ -11,7 +13,7 @@ import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.product_service import product_service
from app.services.subscription_service import subscription_service
@@ -25,17 +27,18 @@ from app.modules.catalog.schemas import (
ProductResponse,
ProductToggleResponse,
ProductUpdate,
)
from app.modules.catalog.schemas import (
VendorDirectProductCreate,
VendorProductCreateResponse,
)
router = APIRouter(prefix="/products")
vendor_router = APIRouter(
prefix="/products",
dependencies=[Depends(require_module_access("catalog"))],
)
logger = logging.getLogger(__name__)
@router.get("", response_model=ProductListResponse)
@vendor_router.get("", response_model=ProductListResponse)
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
@@ -70,7 +73,7 @@ def get_vendor_products(
)
@router.get("/{product_id}", response_model=ProductDetailResponse)
@vendor_router.get("/{product_id}", response_model=ProductDetailResponse)
def get_product_details(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
@@ -84,7 +87,7 @@ def get_product_details(
return ProductDetailResponse.model_validate(product)
@router.post("", response_model=ProductResponse)
@vendor_router.post("", response_model=ProductResponse)
def add_product_to_catalog(
product_data: ProductCreate,
current_user: UserContext = Depends(get_current_vendor_api),
@@ -111,7 +114,7 @@ def add_product_to_catalog(
return ProductResponse.model_validate(product)
@router.post("/create", response_model=VendorProductCreateResponse)
@vendor_router.post("/create", response_model=VendorProductCreateResponse)
def create_product_direct(
product_data: VendorDirectProductCreate,
current_user: UserContext = Depends(get_current_vendor_api),
@@ -155,7 +158,7 @@ def create_product_direct(
)
@router.put("/{product_id}", response_model=ProductResponse)
@vendor_router.put("/{product_id}", response_model=ProductResponse)
def update_product(
product_id: int,
product_data: ProductUpdate,
@@ -179,7 +182,7 @@ def update_product(
return ProductResponse.model_validate(product)
@router.delete("/{product_id}", response_model=ProductDeleteResponse)
@vendor_router.delete("/{product_id}", response_model=ProductDeleteResponse)
def remove_product_from_catalog(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
@@ -199,7 +202,7 @@ def remove_product_from_catalog(
return ProductDeleteResponse(message=f"Product {product_id} removed from catalog")
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
@vendor_router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
def publish_from_marketplace(
marketplace_product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
@@ -230,7 +233,7 @@ def publish_from_marketplace(
return ProductResponse.model_validate(product)
@router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
@vendor_router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
def toggle_product_active(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
@@ -253,7 +256,7 @@ def toggle_product_active(
)
@router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
@vendor_router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
def toggle_product_featured(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),

View File

@@ -2,43 +2,33 @@
"""
Marketplace module admin routes.
This module wraps the existing admin marketplace routes and adds
module-based access control. Routes are re-exported from the
original location with the module access dependency.
This module aggregates all marketplace admin routers into a single router
for auto-discovery. Routes are defined in dedicated files with module-based
access control.
Includes:
- /marketplace/* - Marketplace monitoring
- /products/* - Marketplace product catalog browsing
- /marketplace-import-jobs/* - Marketplace import job monitoring
- /letzshop/* - Letzshop integration
"""
import importlib
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from .admin_products import admin_products_router
from .admin_marketplace import admin_marketplace_router
from .admin_letzshop import admin_letzshop_router
from app.api.deps import require_module_access
# Create aggregate router for auto-discovery
# The router is named 'admin_router' for auto-discovery compatibility
admin_router = APIRouter()
# Import original routers using importlib to avoid circular imports
# (direct import triggers app.api.v1.admin.__init__.py which imports us)
_marketplace_module = importlib.import_module("app.api.v1.admin.marketplace")
_letzshop_module = importlib.import_module("app.api.v1.admin.letzshop")
marketplace_original_router = _marketplace_module.router
letzshop_original_router = _letzshop_module.router
# Include marketplace product catalog routes
admin_router.include_router(admin_products_router)
# Create module-aware router for marketplace
admin_router = APIRouter(
prefix="/marketplace",
dependencies=[Depends(require_module_access("marketplace"))],
)
# Include marketplace import jobs routes
admin_router.include_router(admin_marketplace_router)
# Re-export all routes from the original marketplace module
for route in marketplace_original_router.routes:
admin_router.routes.append(route)
# Include letzshop routes
admin_router.include_router(admin_letzshop_router)
# Create separate router for letzshop integration
admin_letzshop_router = APIRouter(
prefix="/letzshop",
dependencies=[Depends(require_module_access("marketplace"))],
)
for route in letzshop_original_router.routes:
admin_letzshop_router.routes.append(route)
__all__ = ["admin_router"]

View File

@@ -1,6 +1,6 @@
# app/api/v1/admin/products.py
# app/modules/marketplace/routes/api/admin_products.py
"""
Admin product catalog endpoints.
Admin marketplace product catalog endpoints.
Provides platform-wide product search and management capabilities:
- Browse all marketplace products across vendors
@@ -8,6 +8,8 @@ Provides platform-wide product search and management capabilities:
- Filter by marketplace, vendor, availability, product type
- View product details and translations
- Copy products to vendor catalogs
All routes require module access control for the 'marketplace' module.
"""
import logging
@@ -16,12 +18,15 @@ from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.services.marketplace_product_service import marketplace_product_service
from app.modules.marketplace.services.marketplace_product_service import marketplace_product_service
from models.schema.auth import UserContext
router = APIRouter(prefix="/products")
admin_products_router = APIRouter(
prefix="/products",
dependencies=[Depends(require_module_access("marketplace"))],
)
logger = logging.getLogger(__name__)
@@ -147,7 +152,7 @@ class AdminProductDetail(BaseModel):
# ============================================================================
@router.get("", response_model=AdminProductListResponse)
@admin_products_router.get("", response_model=AdminProductListResponse)
def get_products(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
@@ -190,7 +195,7 @@ def get_products(
)
@router.get("/stats", response_model=AdminProductStats)
@admin_products_router.get("/stats", response_model=AdminProductStats)
def get_product_stats(
marketplace: str | None = Query(None, description="Filter by marketplace"),
vendor_name: str | None = Query(None, description="Filter by vendor name"),
@@ -204,7 +209,7 @@ def get_product_stats(
return AdminProductStats(**stats)
@router.get("/marketplaces", response_model=MarketplacesResponse)
@admin_products_router.get("/marketplaces", response_model=MarketplacesResponse)
def get_marketplaces(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
@@ -214,7 +219,7 @@ def get_marketplaces(
return MarketplacesResponse(marketplaces=marketplaces)
@router.get("/vendors", response_model=VendorsResponse)
@admin_products_router.get("/vendors", response_model=VendorsResponse)
def get_product_vendors(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
@@ -224,7 +229,7 @@ def get_product_vendors(
return VendorsResponse(vendors=vendors)
@router.post("/copy-to-vendor", response_model=CopyToVendorResponse)
@admin_products_router.post("/copy-to-vendor", response_model=CopyToVendorResponse)
def copy_products_to_vendor(
request: CopyToVendorRequest,
db: Session = Depends(get_db),
@@ -245,11 +250,11 @@ def copy_products_to_vendor(
vendor_id=request.vendor_id,
skip_existing=request.skip_existing,
)
db.commit() # ✅ ARCH: Commit at API level for transaction control
db.commit()
return CopyToVendorResponse(**result)
@router.get("/{product_id}", response_model=AdminProductDetail)
@admin_products_router.get("/{product_id}", response_model=AdminProductDetail)
def get_product_detail(
product_id: int,
db: Session = Depends(get_db),