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

@@ -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

@@ -0,0 +1,160 @@
# 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
- Create/update/remove products from catalog
All routes require module access control for the 'catalog' module.
"""
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.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
from models.schema.auth import UserContext
from app.modules.catalog.schemas import (
CatalogVendor,
CatalogVendorsResponse,
RemoveProductResponse,
VendorProductCreate,
VendorProductCreateResponse,
VendorProductDetail,
VendorProductListItem,
VendorProductListResponse,
VendorProductStats,
VendorProductUpdate,
)
admin_router = APIRouter(
prefix="/vendor-products",
dependencies=[Depends(require_module_access("catalog"))],
)
logger = logging.getLogger(__name__)
# ============================================================================
# Endpoints
# ============================================================================
@admin_router.get("", response_model=VendorProductListResponse)
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
search: str | None = Query(None, description="Search by title or SKU"),
vendor_id: int | None = Query(None, description="Filter by vendor"),
is_active: bool | None = Query(None, description="Filter by active status"),
is_featured: bool | None = Query(None, description="Filter by featured status"),
language: str = Query("en", description="Language for title lookup"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get all products in vendor catalogs with filtering.
This endpoint allows admins to browse products that have been
copied to vendor catalogs from the marketplace repository.
"""
products, total = vendor_product_service.get_products(
db=db,
skip=skip,
limit=limit,
search=search,
vendor_id=vendor_id,
is_active=is_active,
is_featured=is_featured,
language=language,
)
return VendorProductListResponse(
products=[VendorProductListItem(**p) for p in products],
total=total,
skip=skip,
limit=limit,
)
@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),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get vendor product statistics for admin dashboard."""
stats = vendor_product_service.get_product_stats(db, vendor_id=vendor_id)
return VendorProductStats(**stats)
@admin_router.get("/vendors", response_model=CatalogVendorsResponse)
def get_catalog_vendors(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of vendors with products in their catalogs."""
vendors = vendor_product_service.get_catalog_vendors(db)
return CatalogVendorsResponse(vendors=[CatalogVendor(**v) for v in vendors])
@admin_router.get("/{product_id}", response_model=VendorProductDetail)
def get_vendor_product_detail(
product_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get detailed vendor product information including override info."""
product = vendor_product_service.get_product_detail(db, product_id)
return VendorProductDetail(**product)
@admin_router.post("", response_model=VendorProductCreateResponse)
def create_vendor_product(
data: VendorProductCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a new vendor product."""
# Check product limit before creating
subscription_service.check_product_limit(db, data.vendor_id)
product = vendor_product_service.create_product(db, data.model_dump())
db.commit()
return VendorProductCreateResponse(
id=product.id, message="Product created successfully"
)
@admin_router.patch("/{product_id}", response_model=VendorProductDetail)
def update_vendor_product(
product_id: int,
data: VendorProductUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Update a 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()
# Return the updated product detail
product = vendor_product_service.get_product_detail(db, product_id)
return VendorProductDetail(**product)
@admin_router.delete("/{product_id}", response_model=RemoveProductResponse)
def remove_vendor_product(
product_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Remove a product from vendor catalog."""
result = vendor_product_service.remove_product(db, product_id)
db.commit()
return RemoveProductResponse(**result)

View File

@@ -0,0 +1,279 @@
# 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
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.product_service import product_service
from app.services.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
from models.schema.auth import UserContext
from app.modules.catalog.schemas import (
ProductCreate,
ProductDeleteResponse,
ProductDetailResponse,
ProductListResponse,
ProductResponse,
ProductToggleResponse,
ProductUpdate,
VendorDirectProductCreate,
VendorProductCreateResponse,
)
vendor_router = APIRouter(
prefix="/products",
dependencies=[Depends(require_module_access("catalog"))],
)
logger = logging.getLogger(__name__)
@vendor_router.get("", response_model=ProductListResponse)
def get_vendor_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
is_active: bool | None = Query(None),
is_featured: bool | None = Query(None),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get all products in vendor catalog.
Supports filtering by:
- is_active: Filter active/inactive products
- is_featured: Filter featured products
Vendor is determined from JWT token (vendor_id claim).
"""
products, total = product_service.get_vendor_products(
db=db,
vendor_id=current_user.token_vendor_id,
skip=skip,
limit=limit,
is_active=is_active,
is_featured=is_featured,
)
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
skip=skip,
limit=limit,
)
@vendor_router.get("/{product_id}", response_model=ProductDetailResponse)
def get_product_details(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get detailed product information including inventory."""
product = product_service.get_product(
db=db, vendor_id=current_user.token_vendor_id, product_id=product_id
)
return ProductDetailResponse.model_validate(product)
@vendor_router.post("", response_model=ProductResponse)
def add_product_to_catalog(
product_data: ProductCreate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Add a product from marketplace to vendor catalog.
This publishes a MarketplaceProduct to the vendor's public catalog.
"""
# Check product limit before creating
subscription_service.check_product_limit(db, current_user.token_vendor_id)
product = product_service.create_product(
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
)
db.commit()
logger.info(
f"Product {product.id} added to catalog by user {current_user.username} "
f"for vendor {current_user.token_vendor_code}"
)
return ProductResponse.model_validate(product)
@vendor_router.post("/create", response_model=VendorProductCreateResponse)
def create_product_direct(
product_data: VendorDirectProductCreate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create a new product directly without marketplace product.
This creates a Product and ProductTranslation without requiring
an existing MarketplaceProduct.
"""
# Check product limit before creating
subscription_service.check_product_limit(db, current_user.token_vendor_id)
# Build data dict with vendor_id from token
data = {
"vendor_id": current_user.token_vendor_id,
"title": product_data.title,
"brand": product_data.brand,
"vendor_sku": product_data.vendor_sku,
"gtin": product_data.gtin,
"price": product_data.price,
"currency": product_data.currency,
"availability": product_data.availability,
"is_active": product_data.is_active,
"is_featured": product_data.is_featured,
"description": product_data.description,
}
product = vendor_product_service.create_product(db=db, data=data)
db.commit()
logger.info(
f"Product {product.id} created by user {current_user.username} "
f"for vendor {current_user.token_vendor_code}"
)
return VendorProductCreateResponse(
id=product.id,
message="Product created successfully",
)
@vendor_router.put("/{product_id}", response_model=ProductResponse)
def update_product(
product_id: int,
product_data: ProductUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update product in vendor catalog."""
product = product_service.update_product(
db=db,
vendor_id=current_user.token_vendor_id,
product_id=product_id,
product_update=product_data,
)
db.commit()
logger.info(
f"Product {product_id} updated by user {current_user.username} "
f"for vendor {current_user.token_vendor_code}"
)
return ProductResponse.model_validate(product)
@vendor_router.delete("/{product_id}", response_model=ProductDeleteResponse)
def remove_product_from_catalog(
product_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Remove product from vendor catalog."""
product_service.delete_product(
db=db, vendor_id=current_user.token_vendor_id, product_id=product_id
)
db.commit()
logger.info(
f"Product {product_id} removed from catalog by user {current_user.username} "
f"for vendor {current_user.token_vendor_code}"
)
return ProductDeleteResponse(message=f"Product {product_id} removed from catalog")
@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),
db: Session = Depends(get_db),
):
"""
Publish a marketplace product to vendor catalog.
Shortcut endpoint for publishing directly from marketplace import.
"""
# Check product limit before creating
subscription_service.check_product_limit(db, current_user.token_vendor_id)
product_data = ProductCreate(
marketplace_product_id=marketplace_product_id, is_active=True
)
product = product_service.create_product(
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
)
db.commit()
logger.info(
f"Marketplace product {marketplace_product_id} published to catalog "
f"by user {current_user.username} for vendor {current_user.token_vendor_code}"
)
return ProductResponse.model_validate(product)
@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),
db: Session = Depends(get_db),
):
"""Toggle product active status."""
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
product.is_active = not product.is_active
db.commit()
db.refresh(product)
status = "activated" if product.is_active else "deactivated"
logger.info(
f"Product {product_id} {status} for vendor {current_user.token_vendor_code}"
)
return ProductToggleResponse(
message=f"Product {status}", is_active=product.is_active
)
@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),
db: Session = Depends(get_db),
):
"""Toggle product featured status."""
product = product_service.get_product(db, current_user.token_vendor_id, product_id)
product.is_featured = not product.is_featured
db.commit()
db.refresh(product)
status = "featured" if product.is_featured else "unfeatured"
logger.info(
f"Product {product_id} {status} for vendor {current_user.token_vendor_code}"
)
return ProductToggleResponse(
message=f"Product {status}", is_featured=product.is_featured
)