feat: add marketplace products admin UI with copy-to-vendor functionality
- Add admin marketplace products page to browse imported products - Add admin vendor products page to manage vendor catalog - Add product detail pages for both marketplace and vendor products - Implement copy-to-vendor API to copy marketplace products to vendor catalogs - Add vendor product service with CRUD operations - Update sidebar navigation with new product management links - Add integration and unit tests for new endpoints and services 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,9 +35,11 @@ from . import (
|
||||
marketplace,
|
||||
monitoring,
|
||||
notifications,
|
||||
products,
|
||||
settings,
|
||||
users,
|
||||
vendor_domains,
|
||||
vendor_products,
|
||||
vendor_themes,
|
||||
vendors,
|
||||
)
|
||||
@@ -92,6 +94,17 @@ router.include_router(users.router, tags=["admin-users"])
|
||||
router.include_router(dashboard.router, tags=["admin-dashboard"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Marketplace & Imports
|
||||
# ============================================================================
|
||||
|
||||
252
app/api/v1/admin/products.py
Normal file
252
app/api/v1/admin/products.py
Normal file
@@ -0,0 +1,252 @@
|
||||
# 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.database.user import User
|
||||
|
||||
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
|
||||
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: User = 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(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get product statistics for admin dashboard."""
|
||||
stats = marketplace_product_service.get_admin_product_stats(db)
|
||||
return AdminProductStats(**stats)
|
||||
|
||||
|
||||
@router.get("/marketplaces", response_model=MarketplacesResponse)
|
||||
def get_marketplaces(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = 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: User = 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: User = 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: User = 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)
|
||||
241
app/api/v1/admin/vendor_products.py
Normal file
241
app/api/v1/admin/vendor_products.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# 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
|
||||
"""
|
||||
|
||||
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.vendor_product_service import vendor_product_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/vendor-products")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class VendorProductListItem(BaseModel):
|
||||
"""Product item for vendor catalog list view."""
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
marketplace_product_id: int
|
||||
vendor_sku: str | None = None
|
||||
title: str | None = None
|
||||
brand: str | None = None
|
||||
effective_price: float | None = None
|
||||
effective_currency: str | None = None
|
||||
is_active: bool | None = None
|
||||
is_featured: bool | None = None
|
||||
is_digital: bool | None = None
|
||||
image_url: str | None = None
|
||||
source_marketplace: str | None = None
|
||||
source_vendor: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VendorProductListResponse(BaseModel):
|
||||
"""Paginated vendor product list response."""
|
||||
|
||||
products: list[VendorProductListItem]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class VendorProductStats(BaseModel):
|
||||
"""Vendor product statistics."""
|
||||
|
||||
total: int
|
||||
active: int
|
||||
inactive: int
|
||||
featured: int
|
||||
digital: int
|
||||
physical: int
|
||||
by_vendor: dict[str, int]
|
||||
|
||||
|
||||
class CatalogVendor(BaseModel):
|
||||
"""Vendor with products in catalog."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
vendor_code: str
|
||||
|
||||
|
||||
class CatalogVendorsResponse(BaseModel):
|
||||
"""Response for catalog vendors list."""
|
||||
|
||||
vendors: list[CatalogVendor]
|
||||
|
||||
|
||||
class VendorProductDetail(BaseModel):
|
||||
"""Detailed vendor product information."""
|
||||
|
||||
id: int
|
||||
vendor_id: int
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
marketplace_product_id: int
|
||||
vendor_sku: str | None = None
|
||||
# Override info from get_override_info()
|
||||
price: float | None = None
|
||||
price_overridden: bool | None = None
|
||||
price_source: float | None = None
|
||||
sale_price: float | None = None
|
||||
sale_price_overridden: bool | None = None
|
||||
sale_price_source: float | None = None
|
||||
currency: str | None = None
|
||||
currency_overridden: bool | None = None
|
||||
currency_source: str | None = None
|
||||
brand: str | None = None
|
||||
brand_overridden: bool | None = None
|
||||
brand_source: str | None = None
|
||||
condition: str | None = None
|
||||
condition_overridden: bool | None = None
|
||||
condition_source: str | None = None
|
||||
availability: str | None = None
|
||||
availability_overridden: bool | None = None
|
||||
availability_source: str | None = None
|
||||
primary_image_url: str | None = None
|
||||
primary_image_url_overridden: bool | None = None
|
||||
primary_image_url_source: str | None = None
|
||||
is_digital: bool | None = None
|
||||
product_type: str | None = None
|
||||
# Vendor-specific fields
|
||||
is_featured: bool | None = None
|
||||
is_active: bool | None = None
|
||||
display_order: int | None = None
|
||||
min_quantity: int | None = None
|
||||
max_quantity: int | None = None
|
||||
# Supplier tracking
|
||||
supplier: str | None = None
|
||||
supplier_product_id: str | None = None
|
||||
supplier_cost: float | None = None
|
||||
margin_percent: float | None = None
|
||||
# Digital fulfillment
|
||||
download_url: str | None = None
|
||||
license_type: str | None = None
|
||||
fulfillment_email_template: str | None = None
|
||||
# Source info
|
||||
source_marketplace: str | None = None
|
||||
source_vendor: str | None = None
|
||||
source_gtin: str | None = None
|
||||
source_sku: str | None = None
|
||||
# Translations
|
||||
marketplace_translations: dict | None = None
|
||||
vendor_translations: dict | None = None
|
||||
# Timestamps
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class RemoveProductResponse(BaseModel):
|
||||
"""Response from product removal."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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: User = 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(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get vendor product statistics for admin dashboard."""
|
||||
stats = vendor_product_service.get_product_stats(db)
|
||||
return VendorProductStats(**stats)
|
||||
|
||||
|
||||
@router.get("/vendors", response_model=CatalogVendorsResponse)
|
||||
def get_catalog_vendors(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = 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: User = 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.delete("/{product_id}", response_model=RemoveProductResponse)
|
||||
def remove_vendor_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = 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)
|
||||
Reference in New Issue
Block a user