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:
@@ -603,5 +603,301 @@ class MarketplaceProductService:
|
||||
return normalized
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Admin-specific methods for marketplace product management
|
||||
# =========================================================================
|
||||
|
||||
def get_admin_products(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
marketplace: str | None = None,
|
||||
vendor_name: str | None = None,
|
||||
availability: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
is_digital: bool | None = None,
|
||||
language: str = "en",
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get marketplace products for admin with search and filtering.
|
||||
|
||||
Returns:
|
||||
Tuple of (products list as dicts, total count)
|
||||
"""
|
||||
query = db.query(MarketplaceProduct).options(
|
||||
joinedload(MarketplaceProduct.translations)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.outerjoin(MarketplaceProductTranslation).filter(
|
||||
or_(
|
||||
MarketplaceProductTranslation.title.ilike(search_term),
|
||||
MarketplaceProduct.gtin.ilike(search_term),
|
||||
MarketplaceProduct.sku.ilike(search_term),
|
||||
MarketplaceProduct.brand.ilike(search_term),
|
||||
MarketplaceProduct.mpn.ilike(search_term),
|
||||
MarketplaceProduct.marketplace_product_id.ilike(search_term),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
if marketplace:
|
||||
query = query.filter(MarketplaceProduct.marketplace == marketplace)
|
||||
|
||||
if vendor_name:
|
||||
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
|
||||
|
||||
if availability:
|
||||
query = query.filter(MarketplaceProduct.availability == availability)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(MarketplaceProduct.is_active == is_active)
|
||||
|
||||
if is_digital is not None:
|
||||
query = query.filter(MarketplaceProduct.is_digital == is_digital)
|
||||
|
||||
total = query.count()
|
||||
|
||||
products = (
|
||||
query.order_by(MarketplaceProduct.updated_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for product in products:
|
||||
title = product.get_title(language)
|
||||
result.append(self._build_admin_product_item(product, title))
|
||||
|
||||
return result, total
|
||||
|
||||
def get_admin_product_stats(self, db: Session) -> dict:
|
||||
"""Get product statistics for admin dashboard."""
|
||||
from sqlalchemy import func
|
||||
|
||||
total = db.query(func.count(MarketplaceProduct.id)).scalar() or 0
|
||||
|
||||
active = (
|
||||
db.query(func.count(MarketplaceProduct.id))
|
||||
.filter(MarketplaceProduct.is_active == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
inactive = total - active
|
||||
|
||||
digital = (
|
||||
db.query(func.count(MarketplaceProduct.id))
|
||||
.filter(MarketplaceProduct.is_digital == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
physical = total - digital
|
||||
|
||||
marketplace_counts = (
|
||||
db.query(
|
||||
MarketplaceProduct.marketplace,
|
||||
func.count(MarketplaceProduct.id),
|
||||
)
|
||||
.group_by(MarketplaceProduct.marketplace)
|
||||
.all()
|
||||
)
|
||||
by_marketplace = {mp or "unknown": count for mp, count in marketplace_counts}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"digital": digital,
|
||||
"physical": physical,
|
||||
"by_marketplace": by_marketplace,
|
||||
}
|
||||
|
||||
def get_marketplaces_list(self, db: Session) -> list[str]:
|
||||
"""Get list of unique marketplaces in the product catalog."""
|
||||
marketplaces = (
|
||||
db.query(MarketplaceProduct.marketplace)
|
||||
.distinct()
|
||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
||||
.all()
|
||||
)
|
||||
return [m[0] for m in marketplaces if m[0]]
|
||||
|
||||
def get_source_vendors_list(self, db: Session) -> list[str]:
|
||||
"""Get list of unique vendor names in the product catalog."""
|
||||
vendors = (
|
||||
db.query(MarketplaceProduct.vendor_name)
|
||||
.distinct()
|
||||
.filter(MarketplaceProduct.vendor_name.isnot(None))
|
||||
.all()
|
||||
)
|
||||
return [v[0] for v in vendors if v[0]]
|
||||
|
||||
def get_admin_product_detail(self, db: Session, product_id: int) -> dict:
|
||||
"""Get detailed product information by database ID."""
|
||||
product = (
|
||||
db.query(MarketplaceProduct)
|
||||
.options(joinedload(MarketplaceProduct.translations))
|
||||
.filter(MarketplaceProduct.id == product_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not product:
|
||||
raise MarketplaceProductNotFoundException(
|
||||
f"Marketplace product with ID {product_id} not found"
|
||||
)
|
||||
|
||||
translations = {}
|
||||
for t in product.translations:
|
||||
translations[t.language] = {
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
"short_description": t.short_description,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": product.id,
|
||||
"marketplace_product_id": product.marketplace_product_id,
|
||||
"gtin": product.gtin,
|
||||
"mpn": product.mpn,
|
||||
"sku": product.sku,
|
||||
"brand": product.brand,
|
||||
"marketplace": product.marketplace,
|
||||
"vendor_name": product.vendor_name,
|
||||
"source_url": product.source_url,
|
||||
"price": product.price,
|
||||
"price_numeric": product.price_numeric,
|
||||
"sale_price": product.sale_price,
|
||||
"sale_price_numeric": product.sale_price_numeric,
|
||||
"currency": product.currency,
|
||||
"availability": product.availability,
|
||||
"condition": product.condition,
|
||||
"image_link": product.image_link,
|
||||
"additional_images": product.additional_images,
|
||||
"is_active": product.is_active,
|
||||
"is_digital": product.is_digital,
|
||||
"product_type_enum": product.product_type_enum,
|
||||
"platform": product.platform,
|
||||
"google_product_category": product.google_product_category,
|
||||
"category_path": product.category_path,
|
||||
"color": product.color,
|
||||
"size": product.size,
|
||||
"weight": product.weight,
|
||||
"weight_unit": product.weight_unit,
|
||||
"translations": translations,
|
||||
"created_at": product.created_at.isoformat() if product.created_at else None,
|
||||
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
|
||||
}
|
||||
|
||||
def copy_to_vendor_catalog(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace_product_ids: list[int],
|
||||
vendor_id: int,
|
||||
skip_existing: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Copy marketplace products to a vendor's catalog.
|
||||
|
||||
Returns:
|
||||
Dict with copied, skipped, failed counts and details
|
||||
"""
|
||||
from models.database.product import Product
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
from app.exceptions import VendorNotFoundException
|
||||
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
|
||||
|
||||
marketplace_products = (
|
||||
db.query(MarketplaceProduct)
|
||||
.filter(MarketplaceProduct.id.in_(marketplace_product_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
if not marketplace_products:
|
||||
raise MarketplaceProductNotFoundException("No marketplace products found")
|
||||
|
||||
copied = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
details = []
|
||||
|
||||
for mp in marketplace_products:
|
||||
try:
|
||||
existing = (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
Product.vendor_id == vendor_id,
|
||||
Product.marketplace_product_id == mp.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
skipped += 1
|
||||
details.append({
|
||||
"id": mp.id,
|
||||
"status": "skipped",
|
||||
"reason": "Already exists in catalog",
|
||||
})
|
||||
continue
|
||||
|
||||
product = Product(
|
||||
vendor_id=vendor_id,
|
||||
marketplace_product_id=mp.id,
|
||||
is_active=True,
|
||||
is_featured=False,
|
||||
)
|
||||
|
||||
db.add(product)
|
||||
copied += 1
|
||||
details.append({"id": mp.id, "status": "copied"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to copy product {mp.id}: {str(e)}")
|
||||
failed += 1
|
||||
details.append({"id": mp.id, "status": "failed", "reason": str(e)})
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Copied {copied} products to vendor {vendor.name} "
|
||||
f"(skipped: {skipped}, failed: {failed})"
|
||||
)
|
||||
|
||||
return {
|
||||
"copied": copied,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"details": details if len(details) <= 100 else None,
|
||||
}
|
||||
|
||||
def _build_admin_product_item(self, product: MarketplaceProduct, title: str | None) -> dict:
|
||||
"""Build a product list item dict for admin view."""
|
||||
return {
|
||||
"id": product.id,
|
||||
"marketplace_product_id": product.marketplace_product_id,
|
||||
"title": title,
|
||||
"brand": product.brand,
|
||||
"gtin": product.gtin,
|
||||
"sku": product.sku,
|
||||
"marketplace": product.marketplace,
|
||||
"vendor_name": product.vendor_name,
|
||||
"price_numeric": product.price_numeric,
|
||||
"currency": product.currency,
|
||||
"availability": product.availability,
|
||||
"image_link": product.image_link,
|
||||
"is_active": product.is_active,
|
||||
"is_digital": product.is_digital,
|
||||
"product_type_enum": product.product_type_enum,
|
||||
"created_at": product.created_at.isoformat() if product.created_at else None,
|
||||
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# Create service instance
|
||||
marketplace_product_service = MarketplaceProductService()
|
||||
|
||||
266
app/services/vendor_product_service.py
Normal file
266
app/services/vendor_product_service.py
Normal file
@@ -0,0 +1,266 @@
|
||||
# app/services/vendor_product_service.py
|
||||
"""
|
||||
Vendor product service for managing vendor-specific product catalogs.
|
||||
|
||||
This module provides:
|
||||
- Vendor product catalog browsing
|
||||
- Product search and filtering
|
||||
- Product statistics
|
||||
- Product removal from catalogs
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.exceptions import ProductNotFoundException
|
||||
from models.database.product import Product
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VendorProductService:
|
||||
"""Service for vendor product catalog operations."""
|
||||
|
||||
def get_products(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
vendor_id: int | None = None,
|
||||
is_active: bool | None = None,
|
||||
is_featured: bool | None = None,
|
||||
language: str = "en",
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get vendor products with search and filtering.
|
||||
|
||||
Returns:
|
||||
Tuple of (products list as dicts, total count)
|
||||
"""
|
||||
query = (
|
||||
db.query(Product)
|
||||
.join(Vendor, Product.vendor_id == Vendor.id)
|
||||
.options(
|
||||
joinedload(Product.vendor),
|
||||
joinedload(Product.marketplace_product),
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(Product.vendor_sku.ilike(search_term))
|
||||
|
||||
if vendor_id:
|
||||
query = query.filter(Product.vendor_id == vendor_id)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Product.is_active == is_active)
|
||||
|
||||
if is_featured is not None:
|
||||
query = query.filter(Product.is_featured == is_featured)
|
||||
|
||||
total = query.count()
|
||||
|
||||
products = (
|
||||
query.order_by(Product.updated_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for product in products:
|
||||
result.append(self._build_product_list_item(product, language))
|
||||
|
||||
return result, total
|
||||
|
||||
def get_product_stats(self, db: Session) -> dict:
|
||||
"""Get vendor product statistics for admin dashboard."""
|
||||
total = db.query(func.count(Product.id)).scalar() or 0
|
||||
|
||||
active = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.is_active == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
inactive = total - active
|
||||
|
||||
featured = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.is_featured == True) # noqa: E712
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Digital/physical counts
|
||||
digital = (
|
||||
db.query(func.count(Product.id))
|
||||
.join(Product.marketplace_product)
|
||||
.filter(Product.marketplace_product.has(is_digital=True))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
physical = total - digital
|
||||
|
||||
# Count by vendor
|
||||
vendor_counts = (
|
||||
db.query(
|
||||
Vendor.name,
|
||||
func.count(Product.id),
|
||||
)
|
||||
.join(Vendor, Product.vendor_id == Vendor.id)
|
||||
.group_by(Vendor.name)
|
||||
.all()
|
||||
)
|
||||
by_vendor = {name or "unknown": count for name, count in vendor_counts}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"featured": featured,
|
||||
"digital": digital,
|
||||
"physical": physical,
|
||||
"by_vendor": by_vendor,
|
||||
}
|
||||
|
||||
def get_catalog_vendors(self, db: Session) -> list[dict]:
|
||||
"""Get list of vendors with products in their catalogs."""
|
||||
vendors = (
|
||||
db.query(Vendor.id, Vendor.name, Vendor.vendor_code)
|
||||
.join(Product, Vendor.id == Product.vendor_id)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"id": v.id, "name": v.name, "vendor_code": v.vendor_code}
|
||||
for v in vendors
|
||||
]
|
||||
|
||||
def get_product_detail(self, db: Session, product_id: int) -> dict:
|
||||
"""Get detailed vendor product information including override info."""
|
||||
product = (
|
||||
db.query(Product)
|
||||
.options(
|
||||
joinedload(Product.vendor),
|
||||
joinedload(Product.marketplace_product),
|
||||
joinedload(Product.translations),
|
||||
)
|
||||
.filter(Product.id == product_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not product:
|
||||
raise ProductNotFoundException(product_id)
|
||||
|
||||
mp = product.marketplace_product
|
||||
override_info = product.get_override_info()
|
||||
|
||||
# Get marketplace product translations
|
||||
mp_translations = {}
|
||||
if mp:
|
||||
for t in mp.translations:
|
||||
mp_translations[t.language] = {
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
"short_description": t.short_description,
|
||||
}
|
||||
|
||||
# Get vendor translations (overrides)
|
||||
vendor_translations = {}
|
||||
for t in product.translations:
|
||||
vendor_translations[t.language] = {
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": product.id,
|
||||
"vendor_id": product.vendor_id,
|
||||
"vendor_name": product.vendor.name if product.vendor else None,
|
||||
"vendor_code": product.vendor.vendor_code if product.vendor else None,
|
||||
"marketplace_product_id": product.marketplace_product_id,
|
||||
"vendor_sku": product.vendor_sku,
|
||||
# Override info
|
||||
**override_info,
|
||||
# Vendor-specific fields
|
||||
"is_featured": product.is_featured,
|
||||
"is_active": product.is_active,
|
||||
"display_order": product.display_order,
|
||||
"min_quantity": product.min_quantity,
|
||||
"max_quantity": product.max_quantity,
|
||||
# Supplier tracking
|
||||
"supplier": product.supplier,
|
||||
"supplier_product_id": product.supplier_product_id,
|
||||
"supplier_cost": product.supplier_cost,
|
||||
"margin_percent": product.margin_percent,
|
||||
# Digital fulfillment
|
||||
"download_url": product.download_url,
|
||||
"license_type": product.license_type,
|
||||
"fulfillment_email_template": product.fulfillment_email_template,
|
||||
# Source info from marketplace product
|
||||
"source_marketplace": mp.marketplace if mp else None,
|
||||
"source_vendor": mp.vendor_name if mp else None,
|
||||
"source_gtin": mp.gtin if mp else None,
|
||||
"source_sku": mp.sku if mp else None,
|
||||
# Translations
|
||||
"marketplace_translations": mp_translations,
|
||||
"vendor_translations": vendor_translations,
|
||||
# Timestamps
|
||||
"created_at": product.created_at.isoformat() if product.created_at else None,
|
||||
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
|
||||
}
|
||||
|
||||
def remove_product(self, db: Session, product_id: int) -> dict:
|
||||
"""Remove a product from vendor catalog."""
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
|
||||
if not product:
|
||||
raise ProductNotFoundException(product_id)
|
||||
|
||||
vendor_name = product.vendor.name if product.vendor else "Unknown"
|
||||
db.delete(product)
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Removed product {product_id} from vendor {vendor_name} catalog")
|
||||
|
||||
return {"message": f"Product removed from {vendor_name}'s catalog"}
|
||||
|
||||
def _build_product_list_item(self, product: Product, language: str) -> dict:
|
||||
"""Build a product list item dict."""
|
||||
mp = product.marketplace_product
|
||||
|
||||
# Get title from marketplace product translations
|
||||
title = None
|
||||
if mp:
|
||||
title = mp.get_title(language)
|
||||
|
||||
return {
|
||||
"id": product.id,
|
||||
"vendor_id": product.vendor_id,
|
||||
"vendor_name": product.vendor.name if product.vendor else None,
|
||||
"vendor_code": product.vendor.vendor_code if product.vendor else None,
|
||||
"marketplace_product_id": product.marketplace_product_id,
|
||||
"vendor_sku": product.vendor_sku,
|
||||
"title": title,
|
||||
"brand": product.effective_brand,
|
||||
"effective_price": product.effective_price,
|
||||
"effective_currency": product.effective_currency,
|
||||
"is_active": product.is_active,
|
||||
"is_featured": product.is_featured,
|
||||
"is_digital": product.is_digital,
|
||||
"image_url": product.effective_primary_image_url,
|
||||
"source_marketplace": mp.marketplace if mp else None,
|
||||
"source_vendor": mp.vendor_name if mp else None,
|
||||
"created_at": product.created_at.isoformat() if product.created_at else None,
|
||||
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# Create service instance
|
||||
vendor_product_service = VendorProductService()
|
||||
Reference in New Issue
Block a user