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

@@ -1,260 +0,0 @@
# app/api/v1/admin/products.py
"""
Admin 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
"""
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
from app.core.database import get_db
from app.services.marketplace_product_service import marketplace_product_service
from models.schema.auth import UserContext
router = APIRouter(prefix="/products")
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
# ============================================================================
@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,
)
@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)
@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)
@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)
@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() # ✅ ARCH: Commit at API level for transaction control
return CopyToVendorResponse(**result)
@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)

View File

@@ -1,159 +0,0 @@
# app/api/v1/admin/vendor_products.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
Architecture Notes:
- All Pydantic schemas are defined in models/schema/vendor_product.py
- Business logic is delegated to vendor_product_service
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
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,
)
router = APIRouter(prefix="/vendor-products")
logger = logging.getLogger(__name__)
# ============================================================================
# Endpoints
# ============================================================================
@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,
)
@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)
@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])
@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)
@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() # ✅ ARCH: Commit at API level for transaction control
return VendorProductCreateResponse(
id=product.id, message="Product created successfully"
)
@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() # ✅ ARCH: Commit at API level for transaction control
# 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)
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() # ✅ ARCH: Commit at API level for transaction control
return RemoveProductResponse(**result)

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

@@ -1,276 +0,0 @@
# app/api/v1/vendor/products.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.
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
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,
)
from app.modules.catalog.schemas import (
VendorDirectProductCreate,
VendorProductCreateResponse,
)
router = APIRouter(prefix="/products")
logger = logging.getLogger(__name__)
@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,
)
@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)
@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)
@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",
)
@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)
@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")
@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)
@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
)
@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
)