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
)

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

@@ -0,0 +1,265 @@
# app/modules/marketplace/routes/api/admin_products.py
"""
Admin marketplace product catalog endpoints.
Provides platform-wide product search and management capabilities:
- Browse all marketplace products across vendors
- Search by title, GTIN, SKU, brand
- 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
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.modules.marketplace.services.marketplace_product_service import marketplace_product_service
from models.schema.auth import UserContext
admin_products_router = APIRouter(
prefix="/products",
dependencies=[Depends(require_module_access("marketplace"))],
)
logger = logging.getLogger(__name__)
# ============================================================================
# Pydantic Models
# ============================================================================
class AdminProductListItem(BaseModel):
"""Product item for admin list view."""
id: int
marketplace_product_id: str
title: str | None = None
brand: str | None = None
gtin: str | None = None
sku: str | None = None
marketplace: str | None = None
vendor_name: str | None = None
price_numeric: float | None = None
currency: str | None = None
availability: str | None = None
image_link: str | None = None
is_active: bool | None = None
is_digital: bool | None = None
product_type_enum: str | None = None
created_at: str | None = None
updated_at: str | None = None
class Config:
from_attributes = True
class AdminProductListResponse(BaseModel):
"""Paginated product list response for admin."""
products: list[AdminProductListItem]
total: int
skip: int
limit: int
class AdminProductStats(BaseModel):
"""Product statistics for admin dashboard."""
total: int
active: int
inactive: int
digital: int
physical: int
by_marketplace: dict[str, int]
class MarketplacesResponse(BaseModel):
"""Response for marketplaces list."""
marketplaces: list[str]
class VendorsResponse(BaseModel):
"""Response for vendors list."""
vendors: list[str]
class CopyToVendorRequest(BaseModel):
"""Request body for copying products to vendor catalog."""
marketplace_product_ids: list[int]
vendor_id: int
skip_existing: bool = True
class CopyToVendorResponse(BaseModel):
"""Response from copy to vendor operation."""
copied: int
skipped: int
failed: int
auto_matched: int = 0
limit_reached: bool = False
details: list[dict] | None = None
class AdminProductDetail(BaseModel):
"""Detailed product information for admin view."""
id: int
marketplace_product_id: str | None = None
gtin: str | None = None
mpn: str | None = None
sku: str | None = None
brand: str | None = None
marketplace: str | None = None
vendor_name: str | None = None
source_url: str | None = None
price: str | None = None
price_numeric: float | None = None
sale_price: str | None = None
sale_price_numeric: float | None = None
currency: str | None = None
availability: str | None = None
condition: str | None = None
image_link: str | None = None
additional_images: list | None = None
is_active: bool | None = None
is_digital: bool | None = None
product_type_enum: str | None = None
platform: str | None = None
google_product_category: str | None = None
category_path: str | None = None
color: str | None = None
size: str | None = None
weight: float | None = None
weight_unit: str | None = None
translations: dict | None = None
created_at: str | None = None
updated_at: str | None = None
# ============================================================================
# Endpoints
# ============================================================================
@admin_products_router.get("", response_model=AdminProductListResponse)
def get_products(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
search: str | None = Query(
None, description="Search by title, GTIN, SKU, or brand"
),
marketplace: str | None = Query(None, description="Filter by marketplace"),
vendor_name: str | None = Query(None, description="Filter by vendor name"),
availability: str | None = Query(None, description="Filter by availability"),
is_active: bool | None = Query(None, description="Filter by active status"),
is_digital: bool | None = Query(None, description="Filter by digital products"),
language: str = Query("en", description="Language for title lookup"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get all marketplace products with search and filtering.
This endpoint allows admins to browse the entire product catalog
imported from various marketplaces.
"""
products, total = marketplace_product_service.get_admin_products(
db=db,
skip=skip,
limit=limit,
search=search,
marketplace=marketplace,
vendor_name=vendor_name,
availability=availability,
is_active=is_active,
is_digital=is_digital,
language=language,
)
return AdminProductListResponse(
products=[AdminProductListItem(**p) for p in products],
total=total,
skip=skip,
limit=limit,
)
@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"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get product statistics for admin dashboard."""
stats = marketplace_product_service.get_admin_product_stats(
db, marketplace=marketplace, vendor_name=vendor_name
)
return AdminProductStats(**stats)
@admin_products_router.get("/marketplaces", response_model=MarketplacesResponse)
def get_marketplaces(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of unique marketplaces in the product catalog."""
marketplaces = marketplace_product_service.get_marketplaces_list(db)
return MarketplacesResponse(marketplaces=marketplaces)
@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),
):
"""Get list of unique vendor names in the product catalog."""
vendors = marketplace_product_service.get_source_vendors_list(db)
return VendorsResponse(vendors=vendors)
@admin_products_router.post("/copy-to-vendor", response_model=CopyToVendorResponse)
def copy_products_to_vendor(
request: CopyToVendorRequest,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Copy marketplace products to a vendor's catalog.
This endpoint allows admins to copy products from the master marketplace
product repository to any vendor's product catalog.
The copy creates a new Product entry linked to the MarketplaceProduct,
with default values that can be overridden by the vendor later.
"""
result = marketplace_product_service.copy_to_vendor_catalog(
db=db,
marketplace_product_ids=request.marketplace_product_ids,
vendor_id=request.vendor_id,
skip_existing=request.skip_existing,
)
db.commit()
return CopyToVendorResponse(**result)
@admin_products_router.get("/{product_id}", response_model=AdminProductDetail)
def get_product_detail(
product_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get detailed product information including all translations."""
product = marketplace_product_service.get_admin_product_detail(db, product_id)
return AdminProductDetail(**product)