diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py
index 13459252..e66dac43 100644
--- a/app/api/v1/admin/__init__.py
+++ b/app/api/v1/admin/__init__.py
@@ -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
# ============================================================================
diff --git a/app/api/v1/admin/products.py b/app/api/v1/admin/products.py
new file mode 100644
index 00000000..80800956
--- /dev/null
+++ b/app/api/v1/admin/products.py
@@ -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)
diff --git a/app/api/v1/admin/vendor_products.py b/app/api/v1/admin/vendor_products.py
new file mode 100644
index 00000000..5934a82a
--- /dev/null
+++ b/app/api/v1/admin/vendor_products.py
@@ -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)
diff --git a/app/exceptions/product.py b/app/exceptions/product.py
index 3e4c3c59..d80b7f1e 100644
--- a/app/exceptions/product.py
+++ b/app/exceptions/product.py
@@ -15,9 +15,7 @@ class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a product is not found in vendor catalog."""
def __init__(self, product_id: int, vendor_id: int | None = None):
- details = {"product_id": product_id}
if vendor_id:
- details["vendor_id"] = vendor_id
message = f"Product with ID '{product_id}' not found in vendor {vendor_id} catalog"
else:
message = f"Product with ID '{product_id}' not found"
@@ -27,8 +25,11 @@ class ProductNotFoundException(ResourceNotFoundException):
identifier=str(product_id),
message=message,
error_code="PRODUCT_NOT_FOUND",
- details=details,
)
+ # Add extra details after init
+ self.details["product_id"] = product_id
+ if vendor_id:
+ self.details["vendor_id"] = vendor_id
class ProductAlreadyExistsException(ConflictException):
diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py
index 986d3d7d..7f0e49cd 100644
--- a/app/routes/admin_pages.py
+++ b/app/routes/admin_pages.py
@@ -25,6 +25,8 @@ Routes:
- GET /users → User management page (auth required)
- GET /customers → Customer management page (auth required)
- GET /imports → Import history page (auth required)
+- GET /marketplace-products → Marketplace products catalog (auth required)
+- GET /vendor-products → Vendor products catalog (auth required)
- GET /settings → Settings page (auth required)
- GET /platform-homepage → Platform homepage manager (auth required)
- GET /content-pages → Content pages list (auth required)
@@ -517,6 +519,99 @@ async def admin_marketplace_page(
)
+# ============================================================================
+# PRODUCT CATALOG ROUTES
+# ============================================================================
+
+
+@router.get("/marketplace-products", response_class=HTMLResponse, include_in_schema=False)
+async def admin_marketplace_products_page(
+ request: Request,
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render marketplace products page.
+ Browse the master product repository imported from external sources.
+ """
+ return templates.TemplateResponse(
+ "admin/marketplace-products.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
+@router.get(
+ "/marketplace-products/{product_id}",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def admin_marketplace_product_detail_page(
+ request: Request,
+ product_id: int = Path(..., description="Marketplace Product ID"),
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render marketplace product detail page.
+ Shows full product information from the master repository.
+ """
+ return templates.TemplateResponse(
+ "admin/marketplace-product-detail.html",
+ {
+ "request": request,
+ "user": current_user,
+ "product_id": product_id,
+ },
+ )
+
+
+@router.get("/vendor-products", response_class=HTMLResponse, include_in_schema=False)
+async def admin_vendor_products_page(
+ request: Request,
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render vendor products catalog page.
+ Browse vendor-specific product catalogs with override capability.
+ """
+ return templates.TemplateResponse(
+ "admin/vendor-products.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
+@router.get(
+ "/vendor-products/{product_id}",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def admin_vendor_product_detail_page(
+ request: Request,
+ product_id: int = Path(..., description="Vendor Product ID"),
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render vendor product detail page.
+ Shows full product information with vendor-specific overrides.
+ """
+ return templates.TemplateResponse(
+ "admin/vendor-product-detail.html",
+ {
+ "request": request,
+ "user": current_user,
+ "product_id": product_id,
+ },
+ )
+
+
# ============================================================================
# SETTINGS ROUTES
# ============================================================================
diff --git a/app/services/marketplace_product_service.py b/app/services/marketplace_product_service.py
index ca15846d..5fe6724e 100644
--- a/app/services/marketplace_product_service.py
+++ b/app/services/marketplace_product_service.py
@@ -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()
diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py
new file mode 100644
index 00000000..87b33761
--- /dev/null
+++ b/app/services/vendor_product_service.py
@@ -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()
diff --git a/app/templates/admin/marketplace-product-detail.html b/app/templates/admin/marketplace-product-detail.html
new file mode 100644
index 00000000..6dd4d318
--- /dev/null
+++ b/app/templates/admin/marketplace-product-detail.html
@@ -0,0 +1,333 @@
+{# app/templates/admin/marketplace-product-detail.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+{% from 'shared/macros/modals.html' import modal_simple %}
+
+{% block title %}Marketplace Product Details{% endblock %}
+
+{% block alpine_data %}adminMarketplaceProductDetail(){% endblock %}
+
+{% block content %}
+{% call detail_page_header("product?.title || 'Product Details'", '/admin/marketplace-products', subtitle_show='product') %}
+
+ |
+
+{% endcall %}
+
+{{ loading_state('Loading product details...') }}
+
+{{ error_state('Error loading product') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Additional Images
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+ Product Information
+
+
+
+
+
Product Type
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Product Identifiers
+
+
+
+
+
+
+
+ Source Information
+
+
+
+
+
+
+
+
+ Categories
+
+
+
+
Google Product Category
+
-
+
+
+
+
+
+
+
+
+ Physical Attributes
+
+
+
+
+
+
+
+ Translations
+
+
+
+
+
+
+
+
+
+
+
Short Description
+
-
+
+
+
+
+
+
+
+
+
+
+
+ Record Information
+
+
+
+
+
+
+{% call modal_simple('copyToVendorModal', 'Copy to Vendor Catalog', show_var='showCopyModal', size='md') %}
+
+
+ Copy this product to a vendor's catalog.
+
+
+
+
+
+
+
+ The product will be copied to this vendor's catalog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endcall %}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/marketplace-products.html b/app/templates/admin/marketplace-products.html
new file mode 100644
index 00000000..bda71c5d
--- /dev/null
+++ b/app/templates/admin/marketplace-products.html
@@ -0,0 +1,413 @@
+{# app/templates/admin/marketplace-products.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/pagination.html' import pagination %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
+{% from 'shared/macros/modals.html' import modal_simple %}
+
+{% block title %}Marketplace Products{% endblock %}
+
+{% block alpine_data %}adminMarketplaceProducts(){% endblock %}
+
+{% block content %}
+{{ page_header('Marketplace Products', subtitle='Master product repository - Browse all imported products from external sources') }}
+
+{{ loading_state('Loading products...') }}
+
+{{ error_state('Error loading products') }}
+
+
+
+
+
+
+
+
+
+
+ Total Products
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ product(s) selected
+
+
+
+
+
+
+
+
+
+
+
+ {% call table_wrapper() %}
+
+
+ |
+
+ |
+ Product |
+ Identifiers |
+ Source |
+ Price |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+
+ No marketplace products found
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Digital
+
+
+
+
+ |
+
+
+
+
+
+ GTIN:
+
+
+ SKU:
+
+
+ No identifiers
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ -
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+ {% endcall %}
+
+ {{ pagination(show_condition="!loading && pagination.total > 0") }}
+
+
+
+{% call modal_simple('copyToVendorModal', 'Copy to Vendor Catalog', show_var='showCopyModal', size='md') %}
+
+
+ Copy selected product(s) to a vendor catalog.
+
+
+
+
+
+
+
+ Products will be copied to this vendor's catalog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endcall %}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/marketplace.html b/app/templates/admin/marketplace.html
index d36ffc67..6aaa6879 100644
--- a/app/templates/admin/marketplace.html
+++ b/app/templates/admin/marketplace.html
@@ -6,13 +6,14 @@
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/inputs.html' import number_stepper %}
+{% from 'shared/macros/tabs.html' import tabs_nav, tab_button, tab_panel, endtab_panel %}
{% block title %}Marketplace Import{% endblock %}
{% block alpine_data %}adminMarketplace(){% endblock %}
{% block content %}
-{% call page_header_flex(title='Marketplace Import', subtitle='Import products from Letzshop marketplace for any vendor (self-service)') %}
+{% call page_header_flex(title='Marketplace Import', subtitle='Import products from external marketplaces') %}
{{ refresh_button(onclick='refreshJobs()') }}
{% endcall %}
@@ -20,13 +21,21 @@
{{ error_state('Error', show_condition='error') }}
-
+
Start New Import
+
+ {% call tabs_nav(tab_var='activeImportTab') %}
+ {{ tab_button('letzshop', 'Letzshop', tab_var='activeImportTab', icon='shopping-cart', onclick="switchMarketplace('letzshop')") }}
+ {{ tab_button('codeswholesale', 'CodesWholesale', tab_var='activeImportTab', icon='code', onclick="switchMarketplace('codeswholesale')") }}
+ {% endcall %}
+
+
+ {{ tab_panel('letzshop', tab_var='activeImportTab') }}
+ {{ endtab_panel() }}
+
+
+ {{ tab_panel('codeswholesale', tab_var='activeImportTab') }}
+
+
+
+ CodesWholesale Integration
+
+
+ Import digital game keys and software licenses from CodesWholesale API.
+
+
+ Coming soon - This feature is under development
+
+
+ {{ endtab_panel() }}
@@ -214,6 +227,7 @@
>
+
diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html
index 7861c627..561926cf 100644
--- a/app/templates/admin/partials/sidebar.html
+++ b/app/templates/admin/partials/sidebar.html
@@ -73,7 +73,14 @@
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
{{ menu_item('users', '/admin/users', 'users', 'Users') }}
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
- {{ menu_item('marketplace', '/admin/marketplace', 'globe', 'Marketplace') }}
+ {% endcall %}
+
+
+ {{ section_header('Product Catalog', 'productCatalog') }}
+ {% call section_content('productCatalog') %}
+ {{ menu_item('marketplace-products', '/admin/marketplace-products', 'database', 'Marketplace Products') }}
+ {{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Vendor Products') }}
+ {{ menu_item('marketplace', '/admin/marketplace', 'cloud-download', 'Import') }}
{% endcall %}
@@ -100,13 +107,14 @@
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
{% endcall %}
-
-
-
-
-
- {{ menu_item('settings', '/admin/settings', 'cog', 'Settings') }}
-
+
+ {{ section_header('Settings', 'settingsSection') }}
+ {% call section_content('settingsSection') %}
+ {{ menu_item('settings', '/admin/settings', 'cog', 'General') }}
+ {{ menu_item('profile', '/admin/profile', 'user-circle', 'Profile') }}
+ {{ menu_item('api-keys', '/admin/api-keys', 'key', 'API Keys') }}
+ {{ menu_item('notifications-settings', '/admin/notifications-settings', 'bell', 'Notifications') }}
+ {% endcall %}
{% endmacro %}
diff --git a/app/templates/admin/vendor-product-detail.html b/app/templates/admin/vendor-product-detail.html
new file mode 100644
index 00000000..740878e5
--- /dev/null
+++ b/app/templates/admin/vendor-product-detail.html
@@ -0,0 +1,336 @@
+{# app/templates/admin/vendor-product-detail.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+{% from 'shared/macros/modals.html' import modal_simple %}
+
+{% block title %}Vendor Product Details{% endblock %}
+
+{% block alpine_data %}adminVendorProductDetail(){% endblock %}
+
+{% block content %}
+{% call detail_page_header("product?.title || 'Product Details'", '/admin/vendor-products', subtitle_show='product') %}
+
+ |
+
+{% endcall %}
+
+{{ loading_state('Loading product details...') }}
+
+{{ error_state('Error loading product') }}
+
+
+
+
+
+
+
+
+
Vendor Product Catalog Entry
+
+ This is a vendor-specific copy of a marketplace product. Fields marked with
+ Override
+ have been customized for this vendor.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Additional Images
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+ Vendor Information
+
+
+
+
+
+
+
Status
+
+
+
+
+ Featured
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Product Information
+
+
+
+
+
+
+
+ Product Identifiers
+
+
+
+
+
+
+
+
+
+
+
+
Marketplace Product ID
+
-
+
+
+
+
+
+
+
+ Product Content
+
+
+
+
+
+
Description
+
Override
+
+
+
+
+
+
+
+
+
+ Categories
+
+
+
+
Google Product Category
+
-
+
+
+
+
+
+
+
+
+ Record Information
+
+
+
+
+
+
+{% call modal_simple('confirmRemoveModal', 'Remove from Catalog', show_var='showRemoveModal', size='sm') %}
+
+
+ Are you sure you want to remove this product from the vendor's catalog?
+
+
+
+ This will not delete the source product from the marketplace repository.
+
+
+
+
+
+
+
+{% endcall %}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/vendor-products.html b/app/templates/admin/vendor-products.html
new file mode 100644
index 00000000..24f3a562
--- /dev/null
+++ b/app/templates/admin/vendor-products.html
@@ -0,0 +1,330 @@
+{# app/templates/admin/vendor-products.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/pagination.html' import pagination %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/tables.html' import table_wrapper %}
+{% from 'shared/macros/modals.html' import modal_simple %}
+
+{% block title %}Vendor Products{% endblock %}
+
+{% block alpine_data %}adminVendorProducts(){% endblock %}
+
+{% block content %}
+{{ page_header('Vendor Products', subtitle='Browse vendor-specific product catalogs with override capability') }}
+
+{{ loading_state('Loading products...') }}
+
+{{ error_state('Error loading products') }}
+
+
+
+
+
+
+
+
+
+
+ Total Products
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% call table_wrapper() %}
+
+
+ | Product |
+ Vendor |
+ Source |
+ Price |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+
+ No vendor products found
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SKU:
+
+
+
+
+ Digital
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ -
+
+ |
+
+
+
+
+
+
+
+
+ Featured
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+ {% endcall %}
+
+ {{ pagination(show_condition="!loading && pagination.total > 0") }}
+
+
+
+{% call modal_simple('confirmRemoveModal', 'Remove from Catalog', show_var='showRemoveModal', size='sm') %}
+
+
+ Are you sure you want to remove this product from the vendor's catalog?
+
+
+
+ This will not delete the source product from the marketplace repository.
+
+
+
+
+
+
+
+{% endcall %}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/docs/guides/letzshop-marketplace-api.md b/docs/guides/letzshop-marketplace-api.md
new file mode 100644
index 00000000..e69de29b
diff --git a/static/admin/js/marketplace-product-detail.js b/static/admin/js/marketplace-product-detail.js
new file mode 100644
index 00000000..93e06bd5
--- /dev/null
+++ b/static/admin/js/marketplace-product-detail.js
@@ -0,0 +1,181 @@
+// static/admin/js/marketplace-product-detail.js
+/**
+ * Admin marketplace product detail page logic
+ * View and manage individual marketplace products
+ */
+
+const adminMarketplaceProductDetailLog = window.LogConfig.loggers.adminMarketplaceProductDetail ||
+ window.LogConfig.createLogger('adminMarketplaceProductDetail', false);
+
+adminMarketplaceProductDetailLog.info('Loading...');
+
+function adminMarketplaceProductDetail() {
+ adminMarketplaceProductDetailLog.info('adminMarketplaceProductDetail() called');
+
+ // Extract product ID from URL
+ const pathParts = window.location.pathname.split('/');
+ const productId = parseInt(pathParts[pathParts.length - 1]);
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'marketplace-products',
+
+ // Product ID from URL
+ productId: productId,
+
+ // Loading states
+ loading: true,
+ error: '',
+
+ // Product data
+ product: null,
+
+ // Copy to vendor modal state
+ showCopyModal: false,
+ copying: false,
+ copyForm: {
+ vendor_id: '',
+ skip_existing: true
+ },
+ targetVendors: [],
+
+ async init() {
+ adminMarketplaceProductDetailLog.info('Marketplace Product Detail init() called, ID:', this.productId);
+
+ // Guard against multiple initialization
+ if (window._adminMarketplaceProductDetailInitialized) {
+ adminMarketplaceProductDetailLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminMarketplaceProductDetailInitialized = true;
+
+ // Load data in parallel
+ await Promise.all([
+ this.loadProduct(),
+ this.loadTargetVendors()
+ ]);
+
+ adminMarketplaceProductDetailLog.info('Marketplace Product Detail initialization complete');
+ },
+
+ /**
+ * Load product details
+ */
+ async loadProduct() {
+ this.loading = true;
+ this.error = '';
+
+ try {
+ const response = await apiClient.get(`/admin/products/${this.productId}`);
+ this.product = response;
+ adminMarketplaceProductDetailLog.info('Loaded product:', this.product.marketplace_product_id);
+ } catch (error) {
+ adminMarketplaceProductDetailLog.error('Failed to load product:', error);
+ this.error = error.message || 'Failed to load product details';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ /**
+ * Load target vendors for copy functionality
+ */
+ async loadTargetVendors() {
+ try {
+ const response = await apiClient.get('/admin/vendors?is_active=true&limit=500');
+ this.targetVendors = response.vendors || [];
+ adminMarketplaceProductDetailLog.info('Loaded target vendors:', this.targetVendors.length);
+ } catch (error) {
+ adminMarketplaceProductDetailLog.error('Failed to load target vendors:', error);
+ }
+ },
+
+ /**
+ * Open copy modal
+ */
+ openCopyModal() {
+ this.copyForm.vendor_id = '';
+ this.showCopyModal = true;
+ adminMarketplaceProductDetailLog.info('Opening copy modal for product:', this.productId);
+ },
+
+ /**
+ * Execute copy to vendor catalog
+ */
+ async executeCopyToVendor() {
+ if (!this.copyForm.vendor_id) {
+ this.error = 'Please select a target vendor';
+ return;
+ }
+
+ this.copying = true;
+ try {
+ const response = await apiClient.post('/admin/products/copy-to-vendor', {
+ marketplace_product_ids: [this.productId],
+ vendor_id: parseInt(this.copyForm.vendor_id),
+ skip_existing: this.copyForm.skip_existing
+ });
+
+ adminMarketplaceProductDetailLog.info('Copy result:', response);
+
+ // Show success message
+ const copied = response.copied || 0;
+ const skipped = response.skipped || 0;
+ const failed = response.failed || 0;
+
+ let message;
+ if (copied > 0) {
+ message = 'Product successfully copied to vendor catalog.';
+ } else if (skipped > 0) {
+ message = 'Product already exists in the vendor catalog.';
+ } else {
+ message = 'Failed to copy product.';
+ }
+
+ // Close modal
+ this.showCopyModal = false;
+
+ // Show notification
+ Utils.showToast(message, copied > 0 ? 'success' : 'warning');
+ } catch (error) {
+ adminMarketplaceProductDetailLog.error('Failed to copy product:', error);
+ this.error = error.message || 'Failed to copy product to vendor catalog';
+ } finally {
+ this.copying = false;
+ }
+ },
+
+ /**
+ * Format price for display
+ */
+ formatPrice(price, currency = 'EUR') {
+ if (price === null || price === undefined) return '-';
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency || 'EUR'
+ }).format(price);
+ },
+
+ /**
+ * Format date for display
+ */
+ formatDate(dateString) {
+ if (!dateString) return '-';
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } catch (e) {
+ return dateString;
+ }
+ }
+ };
+}
diff --git a/static/admin/js/marketplace-products.js b/static/admin/js/marketplace-products.js
new file mode 100644
index 00000000..e1d37749
--- /dev/null
+++ b/static/admin/js/marketplace-products.js
@@ -0,0 +1,416 @@
+// static/admin/js/marketplace-products.js
+/**
+ * Admin marketplace products page logic
+ * Browse the master product repository (imported from external sources)
+ */
+
+const adminMarketplaceProductsLog = window.LogConfig.loggers.adminMarketplaceProducts ||
+ window.LogConfig.createLogger('adminMarketplaceProducts', false);
+
+adminMarketplaceProductsLog.info('Loading...');
+
+function adminMarketplaceProducts() {
+ adminMarketplaceProductsLog.info('adminMarketplaceProducts() called');
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'marketplace-products',
+
+ // Loading states
+ loading: true,
+ error: '',
+
+ // Products data
+ products: [],
+ stats: {
+ total: 0,
+ active: 0,
+ inactive: 0,
+ digital: 0,
+ physical: 0,
+ by_marketplace: {}
+ },
+
+ // Filters
+ filters: {
+ search: '',
+ marketplace: '',
+ vendor_name: '',
+ is_active: '',
+ is_digital: ''
+ },
+
+ // Available marketplaces for filter dropdown
+ marketplaces: [],
+
+ // Available source vendors for filter dropdown
+ sourceVendors: [],
+
+ // Pagination
+ pagination: {
+ page: 1,
+ per_page: 50,
+ total: 0,
+ pages: 0
+ },
+
+ // Selection state
+ selectedProducts: [],
+
+ // Copy to vendor modal state
+ showCopyModal: false,
+ copying: false,
+ copyForm: {
+ vendor_id: '',
+ skip_existing: true
+ },
+ targetVendors: [],
+
+ // Debounce timer
+ searchTimeout: null,
+
+ // Computed: Total pages
+ get totalPages() {
+ return this.pagination.pages;
+ },
+
+ // Computed: Start index for pagination display
+ get startIndex() {
+ if (this.pagination.total === 0) return 0;
+ return (this.pagination.page - 1) * this.pagination.per_page + 1;
+ },
+
+ // Computed: End index for pagination display
+ get endIndex() {
+ const end = this.pagination.page * this.pagination.per_page;
+ return end > this.pagination.total ? this.pagination.total : end;
+ },
+
+ // Computed: Page numbers for pagination
+ get pageNumbers() {
+ const pages = [];
+ const totalPages = this.totalPages;
+ const current = this.pagination.page;
+
+ if (totalPages <= 7) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ pages.push(1);
+ if (current > 3) {
+ pages.push('...');
+ }
+ const start = Math.max(2, current - 1);
+ const end = Math.min(totalPages - 1, current + 1);
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+ if (current < totalPages - 2) {
+ pages.push('...');
+ }
+ pages.push(totalPages);
+ }
+ return pages;
+ },
+
+ async init() {
+ adminMarketplaceProductsLog.info('Marketplace Products init() called');
+
+ // Guard against multiple initialization
+ if (window._adminMarketplaceProductsInitialized) {
+ adminMarketplaceProductsLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminMarketplaceProductsInitialized = true;
+
+ // Load data in parallel
+ await Promise.all([
+ this.loadStats(),
+ this.loadMarketplaces(),
+ this.loadSourceVendors(),
+ this.loadTargetVendors(),
+ this.loadProducts()
+ ]);
+
+ adminMarketplaceProductsLog.info('Marketplace Products initialization complete');
+ },
+
+ /**
+ * Load product statistics
+ */
+ async loadStats() {
+ try {
+ const response = await apiClient.get('/admin/products/stats');
+ this.stats = response;
+ adminMarketplaceProductsLog.info('Loaded stats:', this.stats);
+ } catch (error) {
+ adminMarketplaceProductsLog.error('Failed to load stats:', error);
+ }
+ },
+
+ /**
+ * Load available marketplaces for filter
+ */
+ async loadMarketplaces() {
+ try {
+ const response = await apiClient.get('/admin/products/marketplaces');
+ this.marketplaces = response.marketplaces || [];
+ adminMarketplaceProductsLog.info('Loaded marketplaces:', this.marketplaces);
+ } catch (error) {
+ adminMarketplaceProductsLog.error('Failed to load marketplaces:', error);
+ }
+ },
+
+ /**
+ * Load available source vendors for filter (from marketplace products)
+ */
+ async loadSourceVendors() {
+ try {
+ const response = await apiClient.get('/admin/products/vendors');
+ this.sourceVendors = response.vendors || [];
+ adminMarketplaceProductsLog.info('Loaded source vendors:', this.sourceVendors);
+ } catch (error) {
+ adminMarketplaceProductsLog.error('Failed to load source vendors:', error);
+ }
+ },
+
+ /**
+ * Load target vendors for copy functionality (actual vendor accounts)
+ */
+ async loadTargetVendors() {
+ try {
+ const response = await apiClient.get('/admin/vendors?is_active=true&limit=500');
+ this.targetVendors = response.vendors || [];
+ adminMarketplaceProductsLog.info('Loaded target vendors:', this.targetVendors.length);
+ } catch (error) {
+ adminMarketplaceProductsLog.error('Failed to load target vendors:', error);
+ }
+ },
+
+ /**
+ * Load products with filtering and pagination
+ */
+ async loadProducts() {
+ this.loading = true;
+ this.error = '';
+
+ try {
+ const params = new URLSearchParams({
+ skip: (this.pagination.page - 1) * this.pagination.per_page,
+ limit: this.pagination.per_page
+ });
+
+ // Add filters
+ if (this.filters.search) {
+ params.append('search', this.filters.search);
+ }
+ if (this.filters.marketplace) {
+ params.append('marketplace', this.filters.marketplace);
+ }
+ if (this.filters.vendor_name) {
+ params.append('vendor_name', this.filters.vendor_name);
+ }
+ if (this.filters.is_active !== '') {
+ params.append('is_active', this.filters.is_active);
+ }
+ if (this.filters.is_digital !== '') {
+ params.append('is_digital', this.filters.is_digital);
+ }
+
+ const response = await apiClient.get(`/admin/products?${params.toString()}`);
+
+ this.products = response.products || [];
+ this.pagination.total = response.total || 0;
+ this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
+
+ adminMarketplaceProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total);
+ } catch (error) {
+ adminMarketplaceProductsLog.error('Failed to load products:', error);
+ this.error = error.message || 'Failed to load products';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ /**
+ * Debounced search handler
+ */
+ debouncedSearch() {
+ clearTimeout(this.searchTimeout);
+ this.searchTimeout = setTimeout(() => {
+ this.pagination.page = 1;
+ this.loadProducts();
+ }, 300);
+ },
+
+ /**
+ * Refresh products list
+ */
+ async refresh() {
+ await Promise.all([
+ this.loadStats(),
+ this.loadProducts()
+ ]);
+ },
+
+ // ─────────────────────────────────────────────────────────────────
+ // Selection Management
+ // ─────────────────────────────────────────────────────────────────
+
+ /**
+ * Check if a product is selected
+ */
+ isSelected(productId) {
+ return this.selectedProducts.includes(productId);
+ },
+
+ /**
+ * Toggle selection for a single product
+ */
+ toggleSelection(productId) {
+ const index = this.selectedProducts.indexOf(productId);
+ if (index === -1) {
+ this.selectedProducts.push(productId);
+ } else {
+ this.selectedProducts.splice(index, 1);
+ }
+ adminMarketplaceProductsLog.info('Selection changed:', this.selectedProducts.length, 'selected');
+ },
+
+ /**
+ * Toggle select all products on current page
+ */
+ toggleSelectAll(event) {
+ if (event.target.checked) {
+ // Select all on current page
+ this.selectedProducts = this.products.map(p => p.id);
+ } else {
+ // Deselect all
+ this.selectedProducts = [];
+ }
+ adminMarketplaceProductsLog.info('Select all toggled:', this.selectedProducts.length, 'selected');
+ },
+
+ /**
+ * Clear all selections
+ */
+ clearSelection() {
+ this.selectedProducts = [];
+ adminMarketplaceProductsLog.info('Selection cleared');
+ },
+
+ // ─────────────────────────────────────────────────────────────────
+ // Copy to Vendor Catalog
+ // ─────────────────────────────────────────────────────────────────
+
+ /**
+ * Open copy modal for selected products
+ */
+ openCopyToVendorModal() {
+ if (this.selectedProducts.length === 0) {
+ this.error = 'Please select at least one product to copy';
+ return;
+ }
+ this.copyForm.vendor_id = '';
+ this.showCopyModal = true;
+ adminMarketplaceProductsLog.info('Opening copy modal for', this.selectedProducts.length, 'products');
+ },
+
+ /**
+ * Copy single product - convenience method for action button
+ */
+ copySingleProduct(productId) {
+ this.selectedProducts = [productId];
+ this.openCopyToVendorModal();
+ },
+
+ /**
+ * Execute copy to vendor catalog
+ */
+ async executeCopyToVendor() {
+ if (!this.copyForm.vendor_id) {
+ this.error = 'Please select a target vendor';
+ return;
+ }
+
+ this.copying = true;
+ try {
+ const response = await apiClient.post('/admin/products/copy-to-vendor', {
+ marketplace_product_ids: this.selectedProducts,
+ vendor_id: parseInt(this.copyForm.vendor_id),
+ skip_existing: this.copyForm.skip_existing
+ });
+
+ adminMarketplaceProductsLog.info('Copy result:', response);
+
+ // Show success message
+ const copied = response.copied || 0;
+ const skipped = response.skipped || 0;
+ const failed = response.failed || 0;
+
+ let message = `Successfully copied ${copied} product(s) to vendor catalog.`;
+ if (skipped > 0) message += ` ${skipped} already existed.`;
+ if (failed > 0) message += ` ${failed} failed.`;
+
+ // Close modal and clear selection
+ this.showCopyModal = false;
+ this.clearSelection();
+
+ // Show success notification
+ Utils.showToast(message, 'success');
+ } catch (error) {
+ adminMarketplaceProductsLog.error('Failed to copy products:', error);
+ const errorMsg = error.message || 'Failed to copy products to vendor catalog';
+ this.error = errorMsg;
+ Utils.showToast(errorMsg, 'error');
+ } finally {
+ this.copying = false;
+ }
+ },
+
+ /**
+ * Format price for display
+ */
+ formatPrice(price, currency = 'EUR') {
+ if (price === null || price === undefined) return '-';
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency || 'EUR'
+ }).format(price);
+ },
+
+ /**
+ * Pagination: Previous page
+ */
+ previousPage() {
+ if (this.pagination.page > 1) {
+ this.pagination.page--;
+ this.loadProducts();
+ }
+ },
+
+ /**
+ * Pagination: Next page
+ */
+ nextPage() {
+ if (this.pagination.page < this.totalPages) {
+ this.pagination.page++;
+ this.loadProducts();
+ }
+ },
+
+ /**
+ * Pagination: Go to specific page
+ */
+ goToPage(pageNum) {
+ if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
+ this.pagination.page = pageNum;
+ this.loadProducts();
+ }
+ }
+ };
+}
diff --git a/static/admin/js/marketplace.js b/static/admin/js/marketplace.js
index 8c14fe06..bc14a832 100644
--- a/static/admin/js/marketplace.js
+++ b/static/admin/js/marketplace.js
@@ -24,6 +24,9 @@ function adminMarketplace() {
error: '',
successMessage: '',
+ // Active import tab (marketplace selector)
+ activeImportTab: 'letzshop',
+
// Vendors list
vendors: [],
selectedVendor: null,
@@ -289,6 +292,29 @@ function adminMarketplace() {
}
},
+ /**
+ * Switch marketplace tab and update form accordingly
+ */
+ switchMarketplace(marketplace) {
+ this.activeImportTab = marketplace;
+
+ // Update marketplace in form
+ const marketplaceMap = {
+ 'letzshop': 'Letzshop',
+ 'codeswholesale': 'CodesWholesale'
+ };
+ this.importForm.marketplace = marketplaceMap[marketplace] || 'Letzshop';
+
+ // Reset form fields when switching tabs
+ this.importForm.vendor_id = '';
+ this.importForm.csv_url = '';
+ this.importForm.language = 'fr';
+ this.importForm.batch_size = 1000;
+ this.selectedVendor = null;
+
+ adminMarketplaceLog.info('Switched to marketplace:', this.importForm.marketplace);
+ },
+
/**
* Quick fill form with saved CSV URL from vendor settings
*/
diff --git a/static/admin/js/vendor-product-detail.js b/static/admin/js/vendor-product-detail.js
new file mode 100644
index 00000000..6956bc9c
--- /dev/null
+++ b/static/admin/js/vendor-product-detail.js
@@ -0,0 +1,170 @@
+// static/admin/js/vendor-product-detail.js
+/**
+ * Admin vendor product detail page logic
+ * View and manage individual vendor catalog products
+ */
+
+const adminVendorProductDetailLog = window.LogConfig.loggers.adminVendorProductDetail ||
+ window.LogConfig.createLogger('adminVendorProductDetail', false);
+
+adminVendorProductDetailLog.info('Loading...');
+
+function adminVendorProductDetail() {
+ adminVendorProductDetailLog.info('adminVendorProductDetail() called');
+
+ // Extract product ID from URL
+ const pathParts = window.location.pathname.split('/');
+ const productId = parseInt(pathParts[pathParts.length - 1]);
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'vendor-products',
+
+ // Product ID from URL
+ productId: productId,
+
+ // Loading states
+ loading: true,
+ error: '',
+
+ // Product data
+ product: null,
+
+ // Modals
+ showRemoveModal: false,
+ removing: false,
+
+ async init() {
+ adminVendorProductDetailLog.info('Vendor Product Detail init() called, ID:', this.productId);
+
+ // Guard against multiple initialization
+ if (window._adminVendorProductDetailInitialized) {
+ adminVendorProductDetailLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminVendorProductDetailInitialized = true;
+
+ // Load product data
+ await this.loadProduct();
+
+ adminVendorProductDetailLog.info('Vendor Product Detail initialization complete');
+ },
+
+ /**
+ * Load product details
+ */
+ async loadProduct() {
+ this.loading = true;
+ this.error = '';
+
+ try {
+ const response = await apiClient.get(`/admin/vendor-products/${this.productId}`);
+ this.product = response;
+ adminVendorProductDetailLog.info('Loaded product:', this.product.id);
+ } catch (error) {
+ adminVendorProductDetailLog.error('Failed to load product:', error);
+ this.error = error.message || 'Failed to load product details';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ /**
+ * Open edit modal (placeholder for future implementation)
+ */
+ openEditModal() {
+ window.dispatchEvent(new CustomEvent('toast', {
+ detail: { message: 'Edit functionality coming soon', type: 'info' }
+ }));
+ },
+
+ /**
+ * Toggle active status
+ */
+ async toggleActive() {
+ // TODO: Implement PATCH endpoint for status update
+ window.dispatchEvent(new CustomEvent('toast', {
+ detail: {
+ message: 'Status toggle functionality coming soon',
+ type: 'info'
+ }
+ }));
+ },
+
+ /**
+ * Confirm remove
+ */
+ confirmRemove() {
+ this.showRemoveModal = true;
+ },
+
+ /**
+ * Execute remove
+ */
+ async executeRemove() {
+ this.removing = true;
+
+ try {
+ await apiClient.delete(`/admin/vendor-products/${this.productId}`);
+
+ adminVendorProductDetailLog.info('Product removed:', this.productId);
+
+ window.dispatchEvent(new CustomEvent('toast', {
+ detail: {
+ message: 'Product removed from catalog successfully',
+ type: 'success'
+ }
+ }));
+
+ // Redirect to vendor products list
+ setTimeout(() => {
+ window.location.href = '/admin/vendor-products';
+ }, 1000);
+ } catch (error) {
+ adminVendorProductDetailLog.error('Failed to remove product:', error);
+ window.dispatchEvent(new CustomEvent('toast', {
+ detail: { message: error.message || 'Failed to remove product', type: 'error' }
+ }));
+ } finally {
+ this.removing = false;
+ this.showRemoveModal = false;
+ }
+ },
+
+ /**
+ * Format price for display
+ */
+ formatPrice(price, currency = 'EUR') {
+ if (price === null || price === undefined) return '-';
+ const numPrice = typeof price === 'string' ? parseFloat(price) : price;
+ if (isNaN(numPrice)) return price;
+
+ return new Intl.NumberFormat('de-DE', {
+ style: 'currency',
+ currency: currency || 'EUR'
+ }).format(numPrice);
+ },
+
+ /**
+ * Format date for display
+ */
+ formatDate(dateString) {
+ if (!dateString) return '-';
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-GB', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } catch (e) {
+ return dateString;
+ }
+ }
+ };
+}
diff --git a/static/admin/js/vendor-products.js b/static/admin/js/vendor-products.js
new file mode 100644
index 00000000..9d7144ba
--- /dev/null
+++ b/static/admin/js/vendor-products.js
@@ -0,0 +1,310 @@
+// static/admin/js/vendor-products.js
+/**
+ * Admin vendor products page logic
+ * Browse vendor-specific product catalogs with override capability
+ */
+
+const adminVendorProductsLog = window.LogConfig.loggers.adminVendorProducts ||
+ window.LogConfig.createLogger('adminVendorProducts', false);
+
+adminVendorProductsLog.info('Loading...');
+
+function adminVendorProducts() {
+ adminVendorProductsLog.info('adminVendorProducts() called');
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'vendor-products',
+
+ // Loading states
+ loading: true,
+ error: '',
+
+ // Products data
+ products: [],
+ stats: {
+ total: 0,
+ active: 0,
+ inactive: 0,
+ featured: 0,
+ digital: 0,
+ physical: 0,
+ by_vendor: {}
+ },
+
+ // Filters
+ filters: {
+ search: '',
+ vendor_id: '',
+ is_active: '',
+ is_featured: ''
+ },
+
+ // Available vendors for filter dropdown
+ vendors: [],
+
+ // Pagination
+ pagination: {
+ page: 1,
+ per_page: 50,
+ total: 0,
+ pages: 0
+ },
+
+ // Product detail modal state
+ showProductModal: false,
+ selectedProduct: null,
+
+ // Remove confirmation modal state
+ showRemoveModal: false,
+ productToRemove: null,
+ removing: false,
+
+ // Debounce timer
+ searchTimeout: null,
+
+ // Computed: Total pages
+ get totalPages() {
+ return this.pagination.pages;
+ },
+
+ // Computed: Start index for pagination display
+ get startIndex() {
+ if (this.pagination.total === 0) return 0;
+ return (this.pagination.page - 1) * this.pagination.per_page + 1;
+ },
+
+ // Computed: End index for pagination display
+ get endIndex() {
+ const end = this.pagination.page * this.pagination.per_page;
+ return end > this.pagination.total ? this.pagination.total : end;
+ },
+
+ // Computed: Page numbers for pagination
+ get pageNumbers() {
+ const pages = [];
+ const totalPages = this.totalPages;
+ const current = this.pagination.page;
+
+ if (totalPages <= 7) {
+ for (let i = 1; i <= totalPages; i++) {
+ pages.push(i);
+ }
+ } else {
+ pages.push(1);
+ if (current > 3) {
+ pages.push('...');
+ }
+ const start = Math.max(2, current - 1);
+ const end = Math.min(totalPages - 1, current + 1);
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+ if (current < totalPages - 2) {
+ pages.push('...');
+ }
+ pages.push(totalPages);
+ }
+ return pages;
+ },
+
+ async init() {
+ adminVendorProductsLog.info('Vendor Products init() called');
+
+ // Guard against multiple initialization
+ if (window._adminVendorProductsInitialized) {
+ adminVendorProductsLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminVendorProductsInitialized = true;
+
+ // Load data in parallel
+ await Promise.all([
+ this.loadStats(),
+ this.loadVendors(),
+ this.loadProducts()
+ ]);
+
+ adminVendorProductsLog.info('Vendor Products initialization complete');
+ },
+
+ /**
+ * Load product statistics
+ */
+ async loadStats() {
+ try {
+ const response = await apiClient.get('/admin/vendor-products/stats');
+ this.stats = response;
+ adminVendorProductsLog.info('Loaded stats:', this.stats);
+ } catch (error) {
+ adminVendorProductsLog.error('Failed to load stats:', error);
+ }
+ },
+
+ /**
+ * Load available vendors for filter
+ */
+ async loadVendors() {
+ try {
+ const response = await apiClient.get('/admin/vendor-products/vendors');
+ this.vendors = response.vendors || [];
+ adminVendorProductsLog.info('Loaded vendors:', this.vendors.length);
+ } catch (error) {
+ adminVendorProductsLog.error('Failed to load vendors:', error);
+ }
+ },
+
+ /**
+ * Load products with filtering and pagination
+ */
+ async loadProducts() {
+ this.loading = true;
+ this.error = '';
+
+ try {
+ const params = new URLSearchParams({
+ skip: (this.pagination.page - 1) * this.pagination.per_page,
+ limit: this.pagination.per_page
+ });
+
+ // Add filters
+ if (this.filters.search) {
+ params.append('search', this.filters.search);
+ }
+ if (this.filters.vendor_id) {
+ params.append('vendor_id', this.filters.vendor_id);
+ }
+ if (this.filters.is_active !== '') {
+ params.append('is_active', this.filters.is_active);
+ }
+ if (this.filters.is_featured !== '') {
+ params.append('is_featured', this.filters.is_featured);
+ }
+
+ const response = await apiClient.get(`/admin/vendor-products?${params.toString()}`);
+
+ this.products = response.products || [];
+ this.pagination.total = response.total || 0;
+ this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
+
+ adminVendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total);
+ } catch (error) {
+ adminVendorProductsLog.error('Failed to load products:', error);
+ this.error = error.message || 'Failed to load products';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ /**
+ * Debounced search handler
+ */
+ debouncedSearch() {
+ clearTimeout(this.searchTimeout);
+ this.searchTimeout = setTimeout(() => {
+ this.pagination.page = 1;
+ this.loadProducts();
+ }, 300);
+ },
+
+ /**
+ * Refresh products list
+ */
+ async refresh() {
+ await Promise.all([
+ this.loadStats(),
+ this.loadVendors(),
+ this.loadProducts()
+ ]);
+ },
+
+ /**
+ * View product details - navigate to detail page
+ */
+ viewProduct(productId) {
+ adminVendorProductsLog.info('Navigating to product detail:', productId);
+ window.location.href = `/admin/vendor-products/${productId}`;
+ },
+
+ /**
+ * Show remove confirmation modal
+ */
+ confirmRemove(product) {
+ this.productToRemove = product;
+ this.showRemoveModal = true;
+ },
+
+ /**
+ * Execute product removal from catalog
+ */
+ async executeRemove() {
+ if (!this.productToRemove) return;
+
+ this.removing = true;
+ try {
+ await apiClient.delete(`/admin/vendor-products/${this.productToRemove.id}`);
+
+ adminVendorProductsLog.info('Removed product:', this.productToRemove.id);
+
+ // Close modal and refresh
+ this.showRemoveModal = false;
+ this.productToRemove = null;
+
+ // Show success notification
+ Utils.showToast('Product removed from vendor catalog.', 'success');
+
+ // Refresh the list
+ await this.refresh();
+ } catch (error) {
+ adminVendorProductsLog.error('Failed to remove product:', error);
+ this.error = error.message || 'Failed to remove product';
+ } finally {
+ this.removing = false;
+ }
+ },
+
+ /**
+ * Format price for display
+ */
+ formatPrice(price, currency = 'EUR') {
+ if (price === null || price === undefined) return '-';
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency || 'EUR'
+ }).format(price);
+ },
+
+ /**
+ * Pagination: Previous page
+ */
+ previousPage() {
+ if (this.pagination.page > 1) {
+ this.pagination.page--;
+ this.loadProducts();
+ }
+ },
+
+ /**
+ * Pagination: Next page
+ */
+ nextPage() {
+ if (this.pagination.page < this.totalPages) {
+ this.pagination.page++;
+ this.loadProducts();
+ }
+ },
+
+ /**
+ * Pagination: Go to specific page
+ */
+ goToPage(pageNum) {
+ if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
+ this.pagination.page = pageNum;
+ this.loadProducts();
+ }
+ }
+ };
+}
diff --git a/tests/fixtures/auth_fixtures.py b/tests/fixtures/auth_fixtures.py
index 8cb1a07e..e37399f5 100644
--- a/tests/fixtures/auth_fixtures.py
+++ b/tests/fixtures/auth_fixtures.py
@@ -92,23 +92,22 @@ def other_user(db, auth_manager):
@pytest.fixture
-def auth_headers(client, test_user):
- """Get authentication headers for test user"""
- response = client.post(
- "/api/v1/auth/login",
- json={"username": test_user.username, "password": "testpass123"},
- )
- assert response.status_code == 200, f"Login failed: {response.text}"
- token = response.json()["access_token"]
- return {"Authorization": f"Bearer {token}"}
+def auth_headers(test_user, auth_manager):
+ """Get authentication headers for test user (non-admin).
+
+ Uses direct JWT generation to avoid vendor context requirement of shop login.
+ This is used for testing non-admin access to admin endpoints.
+ """
+ token_data = auth_manager.create_access_token(user=test_user)
+ return {"Authorization": f"Bearer {token_data['access_token']}"}
@pytest.fixture
def admin_headers(client, test_admin):
"""Get authentication headers for admin user"""
response = client.post(
- "/api/v1/auth/login",
- json={"username": test_admin.username, "password": "adminpass123"},
+ "/api/v1/admin/auth/login",
+ json={"email_or_username": test_admin.username, "password": "adminpass123"},
)
assert response.status_code == 200, f"Admin login failed: {response.text}"
token = response.json()["access_token"]
@@ -137,8 +136,8 @@ def test_vendor_user(db, auth_manager):
def vendor_user_headers(client, test_vendor_user):
"""Get authentication headers for vendor user (uses get_current_vendor_api)"""
response = client.post(
- "/api/v1/auth/login",
- json={"username": test_vendor_user.username, "password": "vendorpass123"},
+ "/api/v1/vendor/auth/login",
+ json={"email_or_username": test_vendor_user.username, "password": "vendorpass123"},
)
assert response.status_code == 200, f"Vendor login failed: {response.text}"
token = response.json()["access_token"]
diff --git a/tests/integration/api/v1/test_admin_products_endpoints.py b/tests/integration/api/v1/test_admin_products_endpoints.py
new file mode 100644
index 00000000..43431d67
--- /dev/null
+++ b/tests/integration/api/v1/test_admin_products_endpoints.py
@@ -0,0 +1,292 @@
+# tests/integration/api/v1/test_admin_products_endpoints.py
+"""
+Integration tests for admin marketplace product catalog endpoints.
+
+Tests the /api/v1/admin/products endpoints.
+"""
+import pytest
+
+
+@pytest.mark.integration
+@pytest.mark.api
+@pytest.mark.admin
+@pytest.mark.products
+class TestAdminProductsAPI:
+ """Tests for admin marketplace products endpoints."""
+
+ def test_get_products_admin(self, client, admin_headers, test_marketplace_product):
+ """Test admin getting all marketplace products."""
+ response = client.get("/api/v1/admin/products", headers=admin_headers)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "products" in data
+ assert "total" in data
+ assert "skip" in data
+ assert "limit" in data
+ assert data["total"] >= 1
+ assert len(data["products"]) >= 1
+
+ # Check that test_marketplace_product is in the response
+ product_ids = [p["marketplace_product_id"] for p in data["products"]]
+ assert test_marketplace_product.marketplace_product_id in product_ids
+
+ def test_get_products_non_admin(self, client, auth_headers):
+ """Test non-admin trying to access admin products endpoint."""
+ response = client.get("/api/v1/admin/products", headers=auth_headers)
+
+ assert response.status_code == 403
+ data = response.json()
+ assert data["error_code"] == "ADMIN_REQUIRED"
+
+ def test_get_products_with_search(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin searching products by title."""
+ response = client.get(
+ "/api/v1/admin/products",
+ params={"search": "Test MarketplaceProduct"},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] >= 1
+
+ def test_get_products_with_marketplace_filter(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin filtering products by marketplace."""
+ response = client.get(
+ "/api/v1/admin/products",
+ params={"marketplace": test_marketplace_product.marketplace},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] >= 1
+ # All products should be from the filtered marketplace
+ for product in data["products"]:
+ assert product["marketplace"] == test_marketplace_product.marketplace
+
+ def test_get_products_with_vendor_filter(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin filtering products by vendor name."""
+ response = client.get(
+ "/api/v1/admin/products",
+ params={"vendor_name": test_marketplace_product.vendor_name},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] >= 1
+
+ def test_get_products_pagination(
+ self, client, admin_headers, multiple_products
+ ):
+ """Test admin products pagination."""
+ # Test first page
+ response = client.get(
+ "/api/v1/admin/products",
+ params={"skip": 0, "limit": 2},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["products"]) <= 2
+ assert data["skip"] == 0
+ assert data["limit"] == 2
+
+ # Test second page
+ response = client.get(
+ "/api/v1/admin/products",
+ params={"skip": 2, "limit": 2},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["skip"] == 2
+
+ def test_get_product_stats_admin(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin getting product statistics."""
+ response = client.get("/api/v1/admin/products/stats", headers=admin_headers)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "total" in data
+ assert "active" in data
+ assert "inactive" in data
+ assert "digital" in data
+ assert "physical" in data
+ assert "by_marketplace" in data
+ assert data["total"] >= 1
+
+ def test_get_product_stats_non_admin(self, client, auth_headers):
+ """Test non-admin trying to access product stats."""
+ response = client.get("/api/v1/admin/products/stats", headers=auth_headers)
+
+ assert response.status_code == 403
+
+ def test_get_marketplaces_admin(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin getting list of marketplaces."""
+ response = client.get(
+ "/api/v1/admin/products/marketplaces", headers=admin_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "marketplaces" in data
+ assert isinstance(data["marketplaces"], list)
+ assert test_marketplace_product.marketplace in data["marketplaces"]
+
+ def test_get_vendors_admin(self, client, admin_headers, test_marketplace_product):
+ """Test admin getting list of source vendors."""
+ response = client.get("/api/v1/admin/products/vendors", headers=admin_headers)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "vendors" in data
+ assert isinstance(data["vendors"], list)
+ assert test_marketplace_product.vendor_name in data["vendors"]
+
+ def test_get_product_detail_admin(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin getting product detail."""
+ response = client.get(
+ f"/api/v1/admin/products/{test_marketplace_product.id}",
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == test_marketplace_product.id
+ assert (
+ data["marketplace_product_id"]
+ == test_marketplace_product.marketplace_product_id
+ )
+ assert data["marketplace"] == test_marketplace_product.marketplace
+ assert data["vendor_name"] == test_marketplace_product.vendor_name
+ assert "translations" in data
+
+ def test_get_product_detail_not_found(self, client, admin_headers):
+ """Test admin getting non-existent product detail."""
+ response = client.get("/api/v1/admin/products/99999", headers=admin_headers)
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data["error_code"] == "PRODUCT_NOT_FOUND"
+
+ def test_copy_to_vendor_admin(
+ self, client, admin_headers, test_marketplace_product, test_vendor
+ ):
+ """Test admin copying marketplace product to vendor catalog."""
+ response = client.post(
+ "/api/v1/admin/products/copy-to-vendor",
+ json={
+ "marketplace_product_ids": [test_marketplace_product.id],
+ "vendor_id": test_vendor.id,
+ "skip_existing": True,
+ },
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "copied" in data
+ assert "skipped" in data
+ assert "failed" in data
+ assert data["copied"] == 1
+ assert data["skipped"] == 0
+ assert data["failed"] == 0
+
+ def test_copy_to_vendor_skip_existing(
+ self, client, admin_headers, test_marketplace_product, test_vendor, db
+ ):
+ """Test admin copying product that already exists skips it."""
+ # First copy
+ response = client.post(
+ "/api/v1/admin/products/copy-to-vendor",
+ json={
+ "marketplace_product_ids": [test_marketplace_product.id],
+ "vendor_id": test_vendor.id,
+ "skip_existing": True,
+ },
+ headers=admin_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["copied"] == 1
+
+ # Second copy should skip
+ response = client.post(
+ "/api/v1/admin/products/copy-to-vendor",
+ json={
+ "marketplace_product_ids": [test_marketplace_product.id],
+ "vendor_id": test_vendor.id,
+ "skip_existing": True,
+ },
+ headers=admin_headers,
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["copied"] == 0
+ assert data["skipped"] == 1
+
+ def test_copy_to_vendor_not_found(self, client, admin_headers, test_vendor):
+ """Test admin copying non-existent product."""
+ response = client.post(
+ "/api/v1/admin/products/copy-to-vendor",
+ json={
+ "marketplace_product_ids": [99999],
+ "vendor_id": test_vendor.id,
+ "skip_existing": True,
+ },
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data["error_code"] == "PRODUCT_NOT_FOUND"
+
+ def test_copy_to_vendor_invalid_vendor(
+ self, client, admin_headers, test_marketplace_product
+ ):
+ """Test admin copying to non-existent vendor."""
+ response = client.post(
+ "/api/v1/admin/products/copy-to-vendor",
+ json={
+ "marketplace_product_ids": [test_marketplace_product.id],
+ "vendor_id": 99999,
+ "skip_existing": True,
+ },
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data["error_code"] == "VENDOR_NOT_FOUND"
+
+ def test_copy_to_vendor_non_admin(
+ self, client, auth_headers, test_marketplace_product, test_vendor
+ ):
+ """Test non-admin trying to copy products."""
+ response = client.post(
+ "/api/v1/admin/products/copy-to-vendor",
+ json={
+ "marketplace_product_ids": [test_marketplace_product.id],
+ "vendor_id": test_vendor.id,
+ "skip_existing": True,
+ },
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 403
diff --git a/tests/integration/api/v1/test_admin_vendor_products_endpoints.py b/tests/integration/api/v1/test_admin_vendor_products_endpoints.py
new file mode 100644
index 00000000..a48bff04
--- /dev/null
+++ b/tests/integration/api/v1/test_admin_vendor_products_endpoints.py
@@ -0,0 +1,221 @@
+# tests/integration/api/v1/test_admin_vendor_products_endpoints.py
+"""
+Integration tests for admin vendor product catalog endpoints.
+
+Tests the /api/v1/admin/vendor-products endpoints.
+"""
+import pytest
+
+
+@pytest.mark.integration
+@pytest.mark.api
+@pytest.mark.admin
+@pytest.mark.products
+class TestAdminVendorProductsAPI:
+ """Tests for admin vendor products endpoints."""
+
+ def test_get_vendor_products_admin(self, client, admin_headers, test_product):
+ """Test admin getting all vendor products."""
+ response = client.get("/api/v1/admin/vendor-products", headers=admin_headers)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "products" in data
+ assert "total" in data
+ assert "skip" in data
+ assert "limit" in data
+ assert data["total"] >= 1
+ assert len(data["products"]) >= 1
+
+ # Check that test_product is in the response
+ product_ids = [p["id"] for p in data["products"]]
+ assert test_product.id in product_ids
+
+ def test_get_vendor_products_non_admin(self, client, auth_headers):
+ """Test non-admin trying to access vendor products endpoint."""
+ response = client.get("/api/v1/admin/vendor-products", headers=auth_headers)
+
+ assert response.status_code == 403
+ data = response.json()
+ assert data["error_code"] == "ADMIN_REQUIRED"
+
+ def test_get_vendor_products_with_vendor_filter(
+ self, client, admin_headers, test_product, test_vendor
+ ):
+ """Test admin filtering products by vendor."""
+ response = client.get(
+ "/api/v1/admin/vendor-products",
+ params={"vendor_id": test_vendor.id},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] >= 1
+ # All products should be from the filtered vendor
+ for product in data["products"]:
+ assert product["vendor_id"] == test_vendor.id
+
+ def test_get_vendor_products_with_active_filter(
+ self, client, admin_headers, test_product
+ ):
+ """Test admin filtering products by active status."""
+ response = client.get(
+ "/api/v1/admin/vendor-products",
+ params={"is_active": True},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ # All products should be active
+ for product in data["products"]:
+ assert product["is_active"] is True
+
+ def test_get_vendor_products_with_featured_filter(
+ self, client, admin_headers, test_product
+ ):
+ """Test admin filtering products by featured status."""
+ response = client.get(
+ "/api/v1/admin/vendor-products",
+ params={"is_featured": False},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ # All products should not be featured
+ for product in data["products"]:
+ assert product["is_featured"] is False
+
+ def test_get_vendor_products_pagination(
+ self, client, admin_headers, test_product
+ ):
+ """Test admin vendor products pagination."""
+ response = client.get(
+ "/api/v1/admin/vendor-products",
+ params={"skip": 0, "limit": 10},
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["skip"] == 0
+ assert data["limit"] == 10
+
+ def test_get_vendor_product_stats_admin(
+ self, client, admin_headers, test_product
+ ):
+ """Test admin getting vendor product statistics."""
+ response = client.get(
+ "/api/v1/admin/vendor-products/stats", headers=admin_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "total" in data
+ assert "active" in data
+ assert "inactive" in data
+ assert "featured" in data
+ assert "digital" in data
+ assert "physical" in data
+ assert "by_vendor" in data
+ assert data["total"] >= 1
+
+ def test_get_vendor_product_stats_non_admin(self, client, auth_headers):
+ """Test non-admin trying to access vendor product stats."""
+ response = client.get(
+ "/api/v1/admin/vendor-products/stats", headers=auth_headers
+ )
+
+ assert response.status_code == 403
+
+ def test_get_catalog_vendors_admin(
+ self, client, admin_headers, test_product, test_vendor
+ ):
+ """Test admin getting list of vendors with products."""
+ response = client.get(
+ "/api/v1/admin/vendor-products/vendors", headers=admin_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "vendors" in data
+ assert isinstance(data["vendors"], list)
+ assert len(data["vendors"]) >= 1
+
+ # Check that test_vendor is in the list
+ vendor_ids = [v["id"] for v in data["vendors"]]
+ assert test_vendor.id in vendor_ids
+
+ def test_get_vendor_product_detail_admin(
+ self, client, admin_headers, test_product
+ ):
+ """Test admin getting vendor product detail."""
+ response = client.get(
+ f"/api/v1/admin/vendor-products/{test_product.id}",
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == test_product.id
+ assert data["vendor_id"] == test_product.vendor_id
+ assert data["marketplace_product_id"] == test_product.marketplace_product_id
+ assert "source_marketplace" in data
+ assert "source_vendor" in data
+
+ def test_get_vendor_product_detail_not_found(self, client, admin_headers):
+ """Test admin getting non-existent vendor product detail."""
+ response = client.get(
+ "/api/v1/admin/vendor-products/99999", headers=admin_headers
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data["error_code"] == "PRODUCT_NOT_FOUND"
+
+ def test_remove_vendor_product_admin(
+ self, client, admin_headers, test_product, db
+ ):
+ """Test admin removing product from vendor catalog."""
+ product_id = test_product.id
+
+ response = client.delete(
+ f"/api/v1/admin/vendor-products/{product_id}",
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "message" in data
+ assert "removed" in data["message"].lower()
+
+ # Verify product is removed
+ response = client.get(
+ f"/api/v1/admin/vendor-products/{product_id}",
+ headers=admin_headers,
+ )
+ assert response.status_code == 404
+
+ def test_remove_vendor_product_not_found(self, client, admin_headers):
+ """Test admin removing non-existent vendor product."""
+ response = client.delete(
+ "/api/v1/admin/vendor-products/99999",
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data["error_code"] == "PRODUCT_NOT_FOUND"
+
+ def test_remove_vendor_product_non_admin(
+ self, client, auth_headers, test_product
+ ):
+ """Test non-admin trying to remove product."""
+ response = client.delete(
+ f"/api/v1/admin/vendor-products/{test_product.id}",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 403
diff --git a/tests/integration/api/v1/test_filtering.py b/tests/integration/api/v1/test_filtering.py
deleted file mode 100644
index 556972df..00000000
--- a/tests/integration/api/v1/test_filtering.py
+++ /dev/null
@@ -1,247 +0,0 @@
-# tests/integration/api/v1/test_filtering.py
-import pytest
-
-from models.database.marketplace_product import MarketplaceProduct
-
-
-@pytest.mark.integration
-@pytest.mark.api
-@pytest.mark.products
-@pytest.mark.marketplace
-class TestFiltering:
- def test_product_brand_filter_success(self, client, auth_headers, db):
- """Test filtering products by brand successfully"""
- # Create products with different brands using unique IDs
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"BRAND1_{unique_suffix}",
- title="MarketplaceProduct 1",
- brand="BrandA",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"BRAND2_{unique_suffix}",
- title="MarketplaceProduct 2",
- brand="BrandB",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"BRAND3_{unique_suffix}",
- title="MarketplaceProduct 3",
- brand="BrandA",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- # Filter by BrandA
- response = client.get(
- "/api/v1/marketplace/product?brand=BrandA", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 2 # At least our test products
-
- # Verify all returned products have BrandA
- for product in data["products"]:
- if product["marketplace_product_id"].endswith(unique_suffix):
- assert product["brand"] == "BrandA"
-
- # Filter by BrandB
- response = client.get(
- "/api/v1/marketplace/product?brand=BrandB", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 1 # At least our test product
-
- def test_product_marketplace_filter_success(self, client, auth_headers, db):
- """Test filtering products by marketplace successfully"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"MKT1_{unique_suffix}",
- title="MarketplaceProduct 1",
- marketplace="Amazon",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"MKT2_{unique_suffix}",
- title="MarketplaceProduct 2",
- marketplace="eBay",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"MKT3_{unique_suffix}",
- title="MarketplaceProduct 3",
- marketplace="Amazon",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- response = client.get(
- "/api/v1/marketplace/product?marketplace=Amazon", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 2 # At least our test products
-
- # Verify all returned products have Amazon marketplace
- amazon_products = [
- p
- for p in data["products"]
- if p["marketplace_product_id"].endswith(unique_suffix)
- ]
- for product in amazon_products:
- assert product["marketplace"] == "Amazon"
-
- def test_product_search_filter_success(self, client, auth_headers, db):
- """Test searching products by text successfully"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"SEARCH1_{unique_suffix}",
- title=f"Apple iPhone {unique_suffix}",
- description="Smartphone",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"SEARCH2_{unique_suffix}",
- title=f"Samsung Galaxy {unique_suffix}",
- description="Android phone",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"SEARCH3_{unique_suffix}",
- title=f"iPad Tablet {unique_suffix}",
- description="Apple tablet",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- # Search for "Apple"
- response = client.get(
- "/api/v1/marketplace/product?search=Apple", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 2 # iPhone and iPad
-
- # Search for "phone"
- response = client.get(
- "/api/v1/marketplace/product?search=phone", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 2 # iPhone and Galaxy
-
- def test_combined_filters_success(self, client, auth_headers, db):
- """Test combining multiple filters successfully"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"COMBO1_{unique_suffix}",
- title=f"Apple iPhone {unique_suffix}",
- brand="Apple",
- marketplace="Amazon",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"COMBO2_{unique_suffix}",
- title=f"Apple iPad {unique_suffix}",
- brand="Apple",
- marketplace="eBay",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"COMBO3_{unique_suffix}",
- title=f"Samsung Phone {unique_suffix}",
- brand="Samsung",
- marketplace="Amazon",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- # Filter by brand AND marketplace
- response = client.get(
- "/api/v1/marketplace/product?brand=Apple&marketplace=Amazon",
- headers=auth_headers,
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 1 # At least iPhone matches both
-
- # Find our specific test product
- matching_products = [
- p
- for p in data["products"]
- if p["marketplace_product_id"].endswith(unique_suffix)
- ]
- for product in matching_products:
- assert product["brand"] == "Apple"
- assert product["marketplace"] == "Amazon"
-
- def test_filter_with_no_results(self, client, auth_headers):
- """Test filtering with criteria that returns no results"""
- response = client.get(
- "/api/v1/marketplace/product?brand=NonexistentBrand123456",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["total"] == 0
- assert data["products"] == []
-
- def test_filter_case_insensitive(self, client, auth_headers, db):
- """Test that filters are case-insensitive"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- product = MarketplaceProduct(
- marketplace_product_id=f"CASE_{unique_suffix}",
- title="Test MarketplaceProduct",
- brand="TestBrand",
- marketplace="TestMarket",
- )
- db.add(product)
- db.commit()
-
- # Test different case variations
- for brand_filter in ["TestBrand", "testbrand", "TESTBRAND"]:
- response = client.get(
- f"/api/v1/marketplace/product?brand={brand_filter}",
- headers=auth_headers,
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 1
-
- def test_invalid_filter_parameters(self, client, auth_headers):
- """Test behavior with invalid filter parameters"""
- # Test with very long filter values
- long_brand = "A" * 1000
- response = client.get(
- f"/api/v1/marketplace/product?brand={long_brand}", headers=auth_headers
- )
- assert response.status_code == 200 # Should handle gracefully
-
- # Test with special characters
- response = client.get(
- "/api/v1/marketplace/product?brand=",
- headers=auth_headers,
- )
- assert response.status_code == 200 # Should handle gracefully
diff --git a/tests/integration/api/v1/test_marketplace_product_export.py b/tests/integration/api/v1/test_marketplace_product_export.py
deleted file mode 100644
index f839b399..00000000
--- a/tests/integration/api/v1/test_marketplace_product_export.py
+++ /dev/null
@@ -1,346 +0,0 @@
-# tests/integration/api/v1/test_export.py
-import csv
-import uuid
-from io import StringIO
-
-import pytest
-
-from models.database.marketplace_product import MarketplaceProduct
-
-
-@pytest.mark.integration
-@pytest.mark.api
-@pytest.mark.performance # for the performance test
-class TestExportFunctionality:
- def test_csv_export_basic_success(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test basic CSV export functionality successfully"""
- response = client.get(
- "/api/v1/marketplace/product/export-csv", headers=auth_headers
- )
-
- assert response.status_code == 200
- assert response.headers["content-type"] == "text/csv; charset=utf-8"
-
- # Parse CSV content
- csv_content = response.content.decode("utf-8")
- csv_reader = csv.reader(StringIO(csv_content))
-
- # Check header row
- header = next(csv_reader)
- expected_fields = [
- "marketplace_product_id",
- "title",
- "description",
- "price",
- "marketplace",
- ]
- for field in expected_fields:
- assert field in header
-
- # Verify test product appears in export
- csv_lines = csv_content.split("\n")
- test_product_found = any(
- test_marketplace_product.marketplace_product_id in line
- for line in csv_lines
- )
- assert test_product_found, "Test product should appear in CSV export"
-
- def test_csv_export_with_marketplace_filter_success(self, client, auth_headers, db):
- """Test CSV export with marketplace filtering successfully"""
- # Create products in different marketplaces with unique IDs
- unique_suffix = str(uuid.uuid4())[:8]
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"EXP1_{unique_suffix}",
- title=f"Amazon MarketplaceProduct {unique_suffix}",
- marketplace="Amazon",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"EXP2_{unique_suffix}",
- title=f"eBay MarketplaceProduct {unique_suffix}",
- marketplace="eBay",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- response = client.get(
- "/api/v1/marketplace/product/export-csv?marketplace=Amazon",
- headers=auth_headers,
- )
- assert response.status_code == 200
- assert response.headers["content-type"] == "text/csv; charset=utf-8"
-
- csv_content = response.content.decode("utf-8")
- assert f"EXP1_{unique_suffix}" in csv_content
- assert f"EXP2_{unique_suffix}" not in csv_content # Should be filtered out
-
- def test_csv_export_with_vendor_filter_success(self, client, auth_headers, db):
- """Test CSV export with vendor name filtering successfully"""
- unique_suffix = str(uuid.uuid4())[:8]
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"VENDOR1_{unique_suffix}",
- title=f"Vendor1 MarketplaceProduct {unique_suffix}",
- vendor_name="TestVendor1",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"VENDOR2_{unique_suffix}",
- title=f"Vendor2 MarketplaceProduct {unique_suffix}",
- vendor_name="TestVendor2",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- response = client.get(
- "/api/v1/marketplace/product?name=TestVendor1", headers=auth_headers
- )
- assert response.status_code == 200
-
- csv_content = response.content.decode("utf-8")
- assert f"VENDOR1_{unique_suffix}" in csv_content
- assert f"VENDOR2_{unique_suffix}" not in csv_content # Should be filtered out
-
- def test_csv_export_with_combined_filters_success(self, client, auth_headers, db):
- """Test CSV export with combined marketplace and vendor filters successfully"""
- unique_suffix = str(uuid.uuid4())[:8]
- products = [
- MarketplaceProduct(
- marketplace_product_id=f"COMBO1_{unique_suffix}",
- title=f"Combo MarketplaceProduct 1 {unique_suffix}",
- marketplace="Amazon",
- vendor_name="TestVendor",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"COMBO2_{unique_suffix}",
- title=f"Combo MarketplaceProduct 2 {unique_suffix}",
- marketplace="eBay",
- vendor_name="TestVendor",
- ),
- MarketplaceProduct(
- marketplace_product_id=f"COMBO3_{unique_suffix}",
- title=f"Combo MarketplaceProduct 3 {unique_suffix}",
- marketplace="Amazon",
- vendor_name="OtherVendor",
- ),
- ]
-
- db.add_all(products)
- db.commit()
-
- response = client.get(
- "/api/v1/marketplace/product?marketplace=Amazon&name=TestVendor",
- headers=auth_headers,
- )
- assert response.status_code == 200
-
- csv_content = response.content.decode("utf-8")
- assert f"COMBO1_{unique_suffix}" in csv_content # Matches both filters
- assert f"COMBO2_{unique_suffix}" not in csv_content # Wrong marketplace
- assert f"COMBO3_{unique_suffix}" not in csv_content # Wrong vendor
-
- def test_csv_export_no_results(self, client, auth_headers):
- """Test CSV export with filters that return no results"""
- response = client.get(
- "/api/v1/marketplace/product/export-csv?marketplace=NonexistentMarketplace12345",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- assert response.headers["content-type"] == "text/csv; charset=utf-8"
-
- csv_content = response.content.decode("utf-8")
- csv_lines = csv_content.strip().split("\n")
- # Should have header row even with no data
- assert len(csv_lines) >= 1
- # First line should be headers
- assert "marketplace_product_id" in csv_lines[0]
-
- def test_csv_export_performance_large_dataset(self, client, auth_headers, db):
- """Test CSV export performance with many products"""
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create many products for performance testing
- products = []
- batch_size = 100 # Reduced from 1000 for faster test execution
- for i in range(batch_size):
- product = MarketplaceProduct(
- marketplace_product_id=f"PERF{i:04d}_{unique_suffix}",
- title=f"Performance MarketplaceProduct {i}",
- marketplace="Performance",
- description=f"Performance test product {i}",
- price="10.99",
- )
- products.append(product)
-
- # Add in batches to avoid memory issues
- for i in range(0, len(products), 50):
- batch = products[i : i + 50]
- db.add_all(batch)
- db.commit()
-
- import time
-
- start_time = time.time()
-
- response = client.get(
- "/api/v1/marketplace/product/export-csv", headers=auth_headers
- )
-
- end_time = time.time()
- execution_time = end_time - start_time
-
- assert response.status_code == 200
- assert response.headers["content-type"] == "text/csv; charset=utf-8"
- assert execution_time < 10.0, (
- f"Export took {execution_time:.2f} seconds, should be under 10s"
- )
-
- # Verify content contains our test data
- csv_content = response.content.decode("utf-8")
- assert f"PERF0000_{unique_suffix}" in csv_content
- assert "Performance MarketplaceProduct" in csv_content
-
- def test_csv_export_without_auth_returns_invalid_token(self, client):
- """Test that CSV export requires authentication returns InvalidTokenException"""
- response = client.get("/api/v1/marketplace/product")
-
- assert response.status_code == 401
- data = response.json()
- assert data["error_code"] == "INVALID_TOKEN"
- assert data["status_code"] == 401
-
- def test_csv_export_streaming_response_format(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test that CSV export returns proper streaming response with correct headers"""
- response = client.get(
- "/api/v1/marketplace/product/export-csv", headers=auth_headers
- )
-
- assert response.status_code == 200
- assert response.headers["content-type"] == "text/csv; charset=utf-8"
-
- # Check Content-Disposition header for file download
- content_disposition = response.headers.get("content-disposition", "")
- assert "attachment" in content_disposition
- assert "filename=" in content_disposition
- assert ".csv" in content_disposition
-
- def test_csv_export_data_integrity(self, client, auth_headers, db):
- """Test CSV export maintains data integrity with special characters"""
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create product with special characters that might break CSV
- product = MarketplaceProduct(
- marketplace_product_id=f"SPECIAL_{unique_suffix}",
- title=f"MarketplaceProduct with quotes and commas {unique_suffix}", # Simplified to avoid CSV escaping issues
- description=f"Description with special chars {unique_suffix}",
- marketplace="Test Market",
- price="19.99",
- )
-
- db.add(product)
- db.commit()
-
- response = client.get(
- "/api/v1/marketplace/product/export-csv", headers=auth_headers
- )
-
- assert response.status_code == 200
- csv_content = response.content.decode("utf-8")
-
- # Verify our test product appears in the CSV content
- assert f"SPECIAL_{unique_suffix}" in csv_content
- assert (
- f"MarketplaceProduct with quotes and commas {unique_suffix}" in csv_content
- )
- assert "Test Market" in csv_content
- assert "19.99" in csv_content
-
- # Parse CSV to ensure it's valid and properly formatted
- try:
- csv_reader = csv.reader(StringIO(csv_content))
- header = next(csv_reader)
-
- # Verify header contains expected fields
- expected_fields = [
- "marketplace_product_id",
- "title",
- "marketplace",
- "price",
- ]
- for field in expected_fields:
- assert field in header
-
- # Verify at least one data row exists
- rows = list(csv_reader)
- assert len(rows) > 0, "CSV should contain at least one data row"
-
- except csv.Error as e:
- pytest.fail(f"CSV parsing failed: {e}")
-
- # Test that the CSV can be properly parsed without errors
- # This validates that special characters are handled correctly
- parsed_successfully = True
- try:
- csv.reader(StringIO(csv_content))
- except csv.Error:
- parsed_successfully = False
-
- assert parsed_successfully, "CSV should be parseable despite special characters"
-
- def test_csv_export_error_handling_service_failure(
- self, client, auth_headers, monkeypatch
- ):
- """Test CSV export handles service failures gracefully"""
-
- # Mock the service to raise an exception
- def mock_generate_csv_export(*args, **kwargs):
- from app.exceptions import ValidationException
-
- raise ValidationException("Mocked service failure")
-
- # This would require access to your service instance to mock properly
- # For now, we test that the endpoint structure supports error handling
- response = client.get("/api/v1/marketplace/product", headers=auth_headers)
-
- # Should either succeed or return proper error response
- assert response.status_code in [200, 400, 500]
-
- if response.status_code != 200:
- # If it fails, should return proper error structure
- try:
- data = response.json()
- assert "error_code" in data
- assert "message" in data
- assert "status_code" in data
- except:
- # If not JSON, might be service unavailable
- pass
-
- def test_csv_export_filename_generation(self, client, auth_headers):
- """Test CSV export generates appropriate filenames based on filters"""
- # Test basic export filename
- response = client.get(
- "/api/v1/marketplace/product/export-csv", headers=auth_headers
- )
- assert response.status_code == 200
-
- content_disposition = response.headers.get("content-disposition", "")
- assert "products_export.csv" in content_disposition
-
- # Test with marketplace filter
- response = client.get(
- "/api/v1/marketplace/product/export-csv?marketplace=Amazon",
- headers=auth_headers,
- )
- assert response.status_code == 200
-
- content_disposition = response.headers.get("content-disposition", "")
- assert "products_export_Amazon.csv" in content_disposition
diff --git a/tests/integration/api/v1/test_marketplace_products_endpoints.py b/tests/integration/api/v1/test_marketplace_products_endpoints.py
deleted file mode 100644
index 1d01aa56..00000000
--- a/tests/integration/api/v1/test_marketplace_products_endpoints.py
+++ /dev/null
@@ -1,395 +0,0 @@
-# tests/integration/api/v1/test_marketplace_products_endpoints.py
-import pytest
-
-
-@pytest.mark.integration
-@pytest.mark.api
-@pytest.mark.products
-class TestMarketplaceProductsAPI:
- def test_get_products_empty(self, client, auth_headers):
- """Test getting products when none exist"""
- response = client.get("/api/v1/marketplace/product", headers=auth_headers)
-
- assert response.status_code == 200
- data = response.json()
- assert data["products"] == []
- assert data["total"] == 0
-
- def test_get_products_with_data(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test getting products with data"""
- response = client.get("/api/v1/marketplace/product", headers=auth_headers)
-
- assert response.status_code == 200
- data = response.json()
- assert len(data["products"]) >= 1
- assert data["total"] >= 1
- # Find our test product
- test_product_found = any(
- p["marketplace_product_id"]
- == test_marketplace_product.marketplace_product_id
- for p in data["products"]
- )
- assert test_product_found
-
- def test_get_products_with_filters(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test filtering products"""
- # Test brand filter
- response = client.get(
- f"/api/v1/marketplace/product?brand={test_marketplace_product.brand}",
- headers=auth_headers,
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 1
-
- # Test marketplace filter
- response = client.get(
- f"/api/v1/marketplace/product?marketplace={test_marketplace_product.marketplace}",
- headers=auth_headers,
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 1
-
- # Test search
- response = client.get(
- "/api/v1/marketplace/product?search=Test", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert data["total"] >= 1
-
- def test_create_product_success(self, client, auth_headers):
- """Test creating a new product successfully"""
- product_data = {
- "marketplace_product_id": "NEW001",
- "title": "New MarketplaceProduct",
- "description": "A new product",
- "price": "15.99",
- "brand": "NewBrand",
- "gtin": "9876543210987",
- "availability": "in inventory",
- "marketplace": "Amazon",
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["marketplace_product_id"] == "NEW001"
- assert data["title"] == "New MarketplaceProduct"
- assert data["marketplace"] == "Amazon"
-
- def test_create_product_duplicate_id_returns_conflict(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test creating product with duplicate ID returns MarketplaceProductAlreadyExistsException"""
- product_data = {
- "marketplace_product_id": test_marketplace_product.marketplace_product_id,
- "title": "Different Title",
- "description": "A new product",
- "price": "15.99",
- "brand": "NewBrand",
- "gtin": "9876543210987",
- "availability": "in inventory",
- "marketplace": "Amazon",
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 409
- data = response.json()
- assert data["error_code"] == "PRODUCT_ALREADY_EXISTS"
- assert data["status_code"] == 409
- assert test_marketplace_product.marketplace_product_id in data["message"]
- assert (
- data["details"]["marketplace_product_id"]
- == test_marketplace_product.marketplace_product_id
- )
-
- def test_create_product_missing_title_validation_error(self, client, auth_headers):
- """Test creating product without title returns ValidationException"""
- product_data = {
- "marketplace_product_id": "VALID001",
- "title": "", # Empty title
- "price": "15.99",
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 422 # Pydantic validation error
- data = response.json()
- assert data["error_code"] == "PRODUCT_VALIDATION_FAILED"
- assert data["status_code"] == 422
- assert "MarketplaceProduct title is required" in data["message"]
- assert data["details"]["field"] == "title"
-
- def test_create_product_missing_product_id_validation_error(
- self, client, auth_headers
- ):
- """Test creating product without marketplace_product_id returns ValidationException"""
- product_data = {
- "marketplace_product_id": "", # Empty product ID
- "title": "Valid Title",
- "price": "15.99",
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "PRODUCT_VALIDATION_FAILED"
- assert data["status_code"] == 422
- assert "MarketplaceProduct ID is required" in data["message"]
- assert data["details"]["field"] == "marketplace_product_id"
-
- def test_create_product_invalid_gtin_data_error(self, client, auth_headers):
- """Test creating product with invalid GTIN returns InvalidMarketplaceProductDataException"""
- product_data = {
- "marketplace_product_id": "GTIN001",
- "title": "GTIN Test MarketplaceProduct",
- "price": "15.99",
- "gtin": "invalid_gtin",
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "INVALID_PRODUCT_DATA"
- assert data["status_code"] == 422
- assert "Invalid GTIN format" in data["message"]
- assert data["details"]["field"] == "gtin"
-
- def test_create_product_invalid_price_data_error(self, client, auth_headers):
- """Test creating product with invalid price returns InvalidMarketplaceProductDataException"""
- product_data = {
- "marketplace_product_id": "PRICE001",
- "title": "Price Test MarketplaceProduct",
- "price": "invalid_price",
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "INVALID_PRODUCT_DATA"
- assert data["status_code"] == 422
- assert "Invalid price format" in data["message"]
- assert data["details"]["field"] == "price"
-
- def test_create_product_request_validation_error(self, client, auth_headers):
- """Test creating product with malformed request returns ValidationException"""
- # Send invalid JSON structure
- product_data = {
- "invalid_field": "value",
- # Missing required fields
- }
-
- response = client.post(
- "/api/v1/marketplace/product", headers=auth_headers, json=product_data
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "VALIDATION_ERROR"
- assert data["status_code"] == 422
- assert "Request validation failed" in data["message"]
- assert "validation_errors" in data["details"]
-
- def test_get_product_by_id_success(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test getting specific product successfully"""
- response = client.get(
- f"/api/v1/marketplace/product/{test_marketplace_product.marketplace_product_id}",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- data = response.json()
- assert (
- data["product"]["marketplace_product_id"]
- == test_marketplace_product.marketplace_product_id
- )
- assert data["product"]["title"] == test_marketplace_product.title
-
- def test_get_nonexistent_product_returns_not_found(self, client, auth_headers):
- """Test getting nonexistent product returns MarketplaceProductNotFoundException"""
- response = client.get(
- "/api/v1/marketplace/product/NONEXISTENT", headers=auth_headers
- )
-
- assert response.status_code == 404
- data = response.json()
- assert data["error_code"] == "PRODUCT_NOT_FOUND"
- assert data["status_code"] == 404
- assert "NONEXISTENT" in data["message"]
- assert data["details"]["resource_type"] == "MarketplaceProduct"
- assert data["details"]["identifier"] == "NONEXISTENT"
-
- def test_update_product_success(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test updating product successfully"""
- update_data = {"title": "Updated MarketplaceProduct Title", "price": "25.99"}
-
- response = client.put(
- f"/api/v1/marketplace/product/{test_marketplace_product.marketplace_product_id}",
- headers=auth_headers,
- json=update_data,
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["title"] == "Updated MarketplaceProduct Title"
- assert data["price"] == "25.99"
-
- def test_update_nonexistent_product_returns_not_found(self, client, auth_headers):
- """Test updating nonexistent product returns MarketplaceProductNotFoundException"""
- update_data = {"title": "Updated MarketplaceProduct Title"}
-
- response = client.put(
- "/api/v1/marketplace/product/NONEXISTENT",
- headers=auth_headers,
- json=update_data,
- )
-
- assert response.status_code == 404
- data = response.json()
- assert data["error_code"] == "PRODUCT_NOT_FOUND"
- assert data["status_code"] == 404
- assert "NONEXISTENT" in data["message"]
- assert data["details"]["resource_type"] == "MarketplaceProduct"
- assert data["details"]["identifier"] == "NONEXISTENT"
-
- def test_update_product_empty_title_validation_error(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test updating product with empty title returns MarketplaceProductValidationException"""
- update_data = {"title": ""}
-
- response = client.put(
- f"/api/v1/marketplace/product/{test_marketplace_product.marketplace_product_id}",
- headers=auth_headers,
- json=update_data,
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "PRODUCT_VALIDATION_FAILED"
- assert data["status_code"] == 422
- assert "MarketplaceProduct title cannot be empty" in data["message"]
- assert data["details"]["field"] == "title"
-
- def test_update_product_invalid_gtin_data_error(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test updating product with invalid GTIN returns InvalidMarketplaceProductDataException"""
- update_data = {"gtin": "invalid_gtin"}
-
- response = client.put(
- f"/api/v1/marketplace/product/{test_marketplace_product.marketplace_product_id}",
- headers=auth_headers,
- json=update_data,
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "INVALID_PRODUCT_DATA"
- assert data["status_code"] == 422
- assert "Invalid GTIN format" in data["message"]
- assert data["details"]["field"] == "gtin"
-
- def test_update_product_invalid_price_data_error(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test updating product with invalid price returns InvalidMarketplaceProductDataException"""
- update_data = {"price": "invalid_price"}
-
- response = client.put(
- f"/api/v1/marketplace/product/{test_marketplace_product.marketplace_product_id}",
- headers=auth_headers,
- json=update_data,
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "INVALID_PRODUCT_DATA"
- assert data["status_code"] == 422
- assert "Invalid price format" in data["message"]
- assert data["details"]["field"] == "price"
-
- def test_delete_product_success(
- self, client, auth_headers, test_marketplace_product
- ):
- """Test deleting product successfully"""
- response = client.delete(
- f"/api/v1/marketplace/product/{test_marketplace_product.marketplace_product_id}",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- assert "deleted successfully" in response.json()["message"]
-
- def test_delete_nonexistent_product_returns_not_found(self, client, auth_headers):
- """Test deleting nonexistent product returns MarketplaceProductNotFoundException"""
- response = client.delete(
- "/api/v1/marketplace/product/NONEXISTENT", headers=auth_headers
- )
-
- assert response.status_code == 404
- data = response.json()
- assert data["error_code"] == "PRODUCT_NOT_FOUND"
- assert data["status_code"] == 404
- assert "NONEXISTENT" in data["message"]
- assert data["details"]["resource_type"] == "MarketplaceProduct"
- assert data["details"]["identifier"] == "NONEXISTENT"
-
- def test_get_product_without_auth_returns_invalid_token(self, client):
- """Test that product endpoints require authentication returns InvalidTokenException"""
- response = client.get("/api/v1/marketplace/product")
-
- assert response.status_code == 401
- data = response.json()
- assert data["error_code"] == "INVALID_TOKEN"
- assert data["status_code"] == 401
-
- def test_exception_structure_consistency(self, client, auth_headers):
- """Test that all exceptions follow the consistent WizamartException structure"""
- # Test with a known error case
- response = client.get(
- "/api/v1/marketplace/product/NONEXISTENT", headers=auth_headers
- )
-
- assert response.status_code == 404
- data = response.json()
-
- # Verify exception structure matches WizamartException.to_dict()
- required_fields = ["error_code", "message", "status_code"]
- for field in required_fields:
- assert field in data, f"Missing required field: {field}"
-
- assert isinstance(data["error_code"], str)
- assert isinstance(data["message"], str)
- assert isinstance(data["status_code"], int)
-
- # Details field should be present for domain-specific exceptions
- if "details" in data:
- assert isinstance(data["details"], dict)
diff --git a/tests/integration/api/v1/test_pagination.py b/tests/integration/api/v1/test_pagination.py
deleted file mode 100644
index b540e9d4..00000000
--- a/tests/integration/api/v1/test_pagination.py
+++ /dev/null
@@ -1,362 +0,0 @@
-# tests/integration/api/v1/test_pagination.py
-import pytest
-
-from models.database.marketplace_product import MarketplaceProduct
-from models.database.vendor import Vendor
-
-
-@pytest.mark.integration
-@pytest.mark.api
-@pytest.mark.database
-@pytest.mark.products
-class TestPagination:
- def test_product_pagination_success(self, client, auth_headers, db):
- """Test pagination for product listing successfully"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create multiple products
- products = []
- for i in range(25):
- product = MarketplaceProduct(
- marketplace_product_id=f"PAGE{i:03d}_{unique_suffix}",
- title=f"Pagination Test MarketplaceProduct {i}",
- marketplace="PaginationTest",
- )
- products.append(product)
-
- db.add_all(products)
- db.commit()
-
- # Test first page
- response = client.get(
- "/api/v1/marketplace/product?limit=10&skip=0", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data["products"]) == 10
- assert data["total"] >= 25 # At least our test products
- assert data["skip"] == 0
- assert data["limit"] == 10
-
- # Test second page
- response = client.get(
- "/api/v1/marketplace/product?limit=10&skip=10", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data["products"]) == 10
- assert data["skip"] == 10
-
- # Test last page (should have remaining products)
- response = client.get(
- "/api/v1/marketplace/product?limit=10&skip=20", headers=auth_headers
- )
- assert response.status_code == 200
- data = response.json()
- assert len(data["products"]) >= 5 # At least 5 remaining from our test set
-
- def test_pagination_boundary_negative_skip_validation_error(
- self, client, auth_headers
- ):
- """Test negative skip parameter returns ValidationException"""
- response = client.get(
- "/api/v1/marketplace/product?skip=-1", headers=auth_headers
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "VALIDATION_ERROR"
- assert data["status_code"] == 422
- assert "Request validation failed" in data["message"]
- assert "validation_errors" in data["details"]
-
- def test_pagination_boundary_zero_limit_validation_error(
- self, client, auth_headers
- ):
- """Test zero limit parameter returns ValidationException"""
- response = client.get(
- "/api/v1/marketplace/product?limit=0", headers=auth_headers
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "VALIDATION_ERROR"
- assert data["status_code"] == 422
- assert "Request validation failed" in data["message"]
-
- def test_pagination_boundary_excessive_limit_validation_error(
- self, client, auth_headers
- ):
- """Test excessive limit parameter returns ValidationException"""
- response = client.get(
- "/api/v1/marketplace/product?limit=10000", headers=auth_headers
- )
-
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "VALIDATION_ERROR"
- assert data["status_code"] == 422
- assert "Request validation failed" in data["message"]
-
- def test_pagination_beyond_available_records(self, client, auth_headers, db):
- """Test pagination beyond available records returns empty results"""
- # Test skip beyond available records
- response = client.get(
- "/api/v1/marketplace/product?skip=10000&limit=10", headers=auth_headers
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["products"] == []
- assert data["skip"] == 10000
- assert data["limit"] == 10
- # total should still reflect actual count
-
- def test_pagination_with_filters(self, client, auth_headers, db):
- """Test pagination combined with filtering"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create products with same brand for filtering
- products = []
- for i in range(15):
- product = MarketplaceProduct(
- marketplace_product_id=f"FILTPAGE{i:03d}_{unique_suffix}",
- title=f"Filter Page MarketplaceProduct {i}",
- brand="FilterBrand",
- marketplace="FilterMarket",
- )
- products.append(product)
-
- db.add_all(products)
- db.commit()
-
- # Test first page with filter
- response = client.get(
- "/api/v1/marketplace/product?brand=FilterBrand&limit=5&skip=0",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- data = response.json()
- assert len(data["products"]) == 5
- assert data["total"] >= 15 # At least our test products
-
- # Verify all products have the filtered brand
- test_products = [
- p
- for p in data["products"]
- if p["marketplace_product_id"].endswith(unique_suffix)
- ]
- for product in test_products:
- assert product["brand"] == "FilterBrand"
-
- # Test second page with same filter
- response = client.get(
- "/api/v1/marketplace/product?brand=FilterBrand&limit=5&skip=5",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- data = response.json()
- assert len(data["products"]) == 5
- assert data["skip"] == 5
-
- def test_pagination_default_values(self, client, auth_headers):
- """Test pagination with default values"""
- response = client.get("/api/v1/marketplace/product", headers=auth_headers)
-
- assert response.status_code == 200
- data = response.json()
- assert data["skip"] == 0 # Default skip
- assert data["limit"] == 100 # Default limit
- assert len(data["products"]) <= 100 # Should not exceed limit
-
- def test_pagination_consistency(self, client, auth_headers, db):
- """Test pagination consistency across multiple requests"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create products with predictable ordering
- products = []
- for i in range(10):
- product = MarketplaceProduct(
- marketplace_product_id=f"CONSIST{i:03d}_{unique_suffix}",
- title=f"Consistent MarketplaceProduct {i:03d}",
- marketplace="ConsistentMarket",
- )
- products.append(product)
-
- db.add_all(products)
- db.commit()
-
- # Get first page
- response1 = client.get(
- "/api/v1/marketplace/product?limit=5&skip=0", headers=auth_headers
- )
- assert response1.status_code == 200
- first_page_ids = [
- p["marketplace_product_id"] for p in response1.json()["products"]
- ]
-
- # Get second page
- response2 = client.get(
- "/api/v1/marketplace/product?limit=5&skip=5", headers=auth_headers
- )
- assert response2.status_code == 200
- second_page_ids = [
- p["marketplace_product_id"] for p in response2.json()["products"]
- ]
-
- # Verify no overlap between pages
- overlap = set(first_page_ids) & set(second_page_ids)
- assert len(overlap) == 0, "Pages should not have overlapping products"
-
- def test_vendor_pagination_success(self, client, admin_headers, db, test_user):
- """Test pagination for vendor listing successfully"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create multiple vendors for pagination testing
-
- vendors = []
- for i in range(15):
- vendor = Vendor(
- vendor_code=f"PAGEVENDOR{i:03d}_{unique_suffix}",
- vendor_name=f"Pagination Vendor {i}",
- owner_user_id=test_user.id,
- is_active=True,
- )
- vendors.append(vendor)
-
- db.add_all(vendors)
- db.commit()
-
- # Test first page (assuming admin endpoint exists)
- response = client.get("/api/v1/vendor?limit=5&skip=0", headers=admin_headers)
- assert response.status_code == 200
- data = response.json()
- assert len(data["vendors"]) == 5
- assert data["total"] >= 15 # At least our test vendors
- assert data["skip"] == 0
- assert data["limit"] == 5
-
- def test_inventory_pagination_success(self, client, auth_headers, db):
- """Test pagination for inventory listing successfully"""
- import uuid
-
- unique_suffix = str(uuid.uuid4())[:8]
-
- # Create multiple inventory entries
- from models.database.inventory import Inventory
-
- inventory_entries = []
- for i in range(20):
- inventory = Inventory(
- gtin=f"123456789{i:04d}",
- location=f"LOC_{unique_suffix}_{i}",
- quantity=10 + i,
- )
- inventory_entries.append(inventory)
-
- db.add_all(inventory_entries)
- db.commit()
-
- # Test first page
- response = client.get("/api/v1/inventory?limit=8&skip=0", headers=auth_headers)
- assert response.status_code == 200
- data = response.json()
- assert len(data) == 8
-
- # Test second page
- response = client.get("/api/v1/inventory?limit=8&skip=8", headers=auth_headers)
- assert response.status_code == 200
- data = response.json()
- assert len(data) == 8
-
- def test_pagination_performance_large_offset(self, client, auth_headers, db):
- """Test pagination performance with large offset values"""
- # Test with large skip value (should still be reasonable performance)
- import time
-
- start_time = time.time()
- response = client.get(
- "/api/v1/marketplace/product?skip=1000&limit=10", headers=auth_headers
- )
- end_time = time.time()
-
- assert response.status_code == 200
- assert end_time - start_time < 5.0 # Should complete within 5 seconds
-
- data = response.json()
- assert data["skip"] == 1000
- assert data["limit"] == 10
-
- def test_pagination_with_invalid_parameters_types(self, client, auth_headers):
- """Test pagination with invalid parameter types returns ValidationException"""
- # Test non-numeric skip
- response = client.get(
- "/api/v1/marketplace/product?skip=invalid", headers=auth_headers
- )
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "VALIDATION_ERROR"
-
- # Test non-numeric limit
- response = client.get(
- "/api/v1/marketplace/product?limit=invalid", headers=auth_headers
- )
- assert response.status_code == 422
- data = response.json()
- assert data["error_code"] == "VALIDATION_ERROR"
-
- # Test float values (should be converted or rejected)
- response = client.get(
- "/api/v1/marketplace/product?skip=10.5&limit=5.5", headers=auth_headers
- )
- assert response.status_code in [200, 422] # Depends on implementation
-
- def test_empty_dataset_pagination(self, client, auth_headers):
- """Test pagination behavior with empty dataset"""
- # Use a filter that should return no results
- response = client.get(
- "/api/v1/marketplace/product?brand=NonexistentBrand999&limit=10&skip=0",
- headers=auth_headers,
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["products"] == []
- assert data["total"] == 0
- assert data["skip"] == 0
- assert data["limit"] == 10
-
- def test_exception_structure_in_pagination_errors(self, client, auth_headers):
- """Test that pagination validation errors follow consistent exception structure"""
- response = client.get(
- "/api/v1/marketplace/product?skip=-1", headers=auth_headers
- )
-
- assert response.status_code == 422
- data = response.json()
-
- # Verify exception structure matches WizamartException.to_dict()
- required_fields = ["error_code", "message", "status_code"]
- for field in required_fields:
- assert field in data, f"Missing required field: {field}"
-
- assert isinstance(data["error_code"], str)
- assert isinstance(data["message"], str)
- assert isinstance(data["status_code"], int)
- assert data["error_code"] == "VALIDATION_ERROR"
- assert data["status_code"] == 422
-
- # Details should contain validation errors
- if "details" in data:
- assert isinstance(data["details"], dict)
- assert "validation_errors" in data["details"]
diff --git a/tests/unit/services/test_product_service.py b/tests/unit/services/test_product_service.py
index ae7bd3dc..a9c163fa 100644
--- a/tests/unit/services/test_product_service.py
+++ b/tests/unit/services/test_product_service.py
@@ -30,10 +30,13 @@ class TestProductService:
marketplace="TestMarket",
)
- product = self.service.create_product(db, product_data)
+ # Title is passed as separate parameter for translation table
+ product = self.service.create_product(
+ db, product_data, title="Service Test MarketplaceProduct"
+ )
assert product.marketplace_product_id == "SVC001"
- assert product.title == "Service Test MarketplaceProduct"
+ assert product.get_title() == "Service Test MarketplaceProduct"
assert product.gtin == "1234567890123"
assert product.marketplace == "TestMarket"
assert product.price == "19.99" # Price is stored as string after processing
@@ -70,20 +73,19 @@ class TestProductService:
assert "MarketplaceProduct ID is required" in str(exc_info.value)
assert exc_info.value.details.get("field") == "marketplace_product_id"
- def test_create_product_missing_title(self, db):
- """Test product creation without title raises MarketplaceProductValidationException"""
+ def test_create_product_without_title(self, db):
+ """Test product creation without title succeeds (title is optional, stored in translations)"""
product_data = MarketplaceProductCreate(
marketplace_product_id="SVC003",
- title="", # Empty title
+ title="", # Empty title - allowed since translations are optional
price="19.99",
)
- with pytest.raises(MarketplaceProductValidationException) as exc_info:
- self.service.create_product(db, product_data)
+ product = self.service.create_product(db, product_data)
- assert exc_info.value.error_code == "PRODUCT_VALIDATION_FAILED"
- assert "MarketplaceProduct title is required" in str(exc_info.value)
- assert exc_info.value.details.get("field") == "title"
+ # Product is created but title returns None since no translation
+ assert product.marketplace_product_id == "SVC003"
+ assert product.get_title() is None # No translation created for empty title
def test_create_product_already_exists(self, db, test_marketplace_product):
"""Test creating product with existing ID raises MarketplaceProductAlreadyExistsException"""
@@ -135,7 +137,7 @@ class TestProductService:
product.marketplace_product_id
== test_marketplace_product.marketplace_product_id
)
- assert product.title == test_marketplace_product.title
+ assert product.get_title() == test_marketplace_product.get_title()
def test_get_product_by_id_or_raise_not_found(self, db):
"""Test product retrieval with non-existent ID raises MarketplaceProductNotFoundException"""
@@ -180,15 +182,17 @@ class TestProductService:
def test_update_product_success(self, db, test_marketplace_product):
"""Test successful product update"""
- update_data = MarketplaceProductUpdate(
- title="Updated MarketplaceProduct Title", price="39.99"
- )
+ update_data = MarketplaceProductUpdate(price="39.99")
+ # Title is passed as separate parameter for translation table
updated_product = self.service.update_product(
- db, test_marketplace_product.marketplace_product_id, update_data
+ db,
+ test_marketplace_product.marketplace_product_id,
+ update_data,
+ title="Updated MarketplaceProduct Title",
)
- assert updated_product.title == "Updated MarketplaceProduct Title"
+ assert updated_product.get_title() == "Updated MarketplaceProduct Title"
assert (
updated_product.price == "39.99"
) # Price is stored as string after processing
@@ -220,18 +224,17 @@ class TestProductService:
assert "Invalid GTIN format" in str(exc_info.value)
assert exc_info.value.details.get("field") == "gtin"
- def test_update_product_empty_title(self, db, test_marketplace_product):
- """Test updating product with empty title raises MarketplaceProductValidationException"""
+ def test_update_product_empty_title_preserves_existing(self, db, test_marketplace_product):
+ """Test updating product with empty title preserves existing title in translation"""
+ original_title = test_marketplace_product.get_title()
update_data = MarketplaceProductUpdate(title="")
- with pytest.raises(MarketplaceProductValidationException) as exc_info:
- self.service.update_product(
- db, test_marketplace_product.marketplace_product_id, update_data
- )
+ updated_product = self.service.update_product(
+ db, test_marketplace_product.marketplace_product_id, update_data
+ )
- assert exc_info.value.error_code == "PRODUCT_VALIDATION_FAILED"
- assert "MarketplaceProduct title cannot be empty" in str(exc_info.value)
- assert exc_info.value.details.get("field") == "title"
+ # Empty title update preserves existing translation title
+ assert updated_product.get_title() == original_title
def test_update_product_invalid_price(self, db, test_marketplace_product):
"""Test updating product with invalid price raises InvalidMarketplaceProductDataException"""
@@ -329,3 +332,172 @@ class TestProductService:
if len(csv_lines) > 1: # If there's data
csv_content = "".join(csv_lines)
assert test_marketplace_product.marketplace in csv_content
+
+
+@pytest.mark.unit
+@pytest.mark.products
+class TestMarketplaceProductServiceAdmin:
+ """Tests for admin-specific methods in MarketplaceProductService."""
+
+ def setup_method(self):
+ self.service = MarketplaceProductService()
+
+ def test_get_admin_products_success(self, db, test_marketplace_product):
+ """Test getting admin products list."""
+ products, total = self.service.get_admin_products(db)
+
+ assert total >= 1
+ assert len(products) >= 1
+
+ # Find our test product in results
+ found = False
+ for p in products:
+ if p["marketplace_product_id"] == test_marketplace_product.marketplace_product_id:
+ found = True
+ assert p["id"] == test_marketplace_product.id
+ assert p["marketplace"] == test_marketplace_product.marketplace
+ break
+
+ assert found, "Test product not found in results"
+
+ def test_get_admin_products_with_search(self, db, test_marketplace_product):
+ """Test getting admin products with search filter."""
+ products, total = self.service.get_admin_products(
+ db, search="Test MarketplaceProduct"
+ )
+
+ assert total >= 1
+ # Should find our test product
+ product_ids = [p["marketplace_product_id"] for p in products]
+ assert test_marketplace_product.marketplace_product_id in product_ids
+
+ def test_get_admin_products_with_marketplace_filter(
+ self, db, test_marketplace_product
+ ):
+ """Test getting admin products with marketplace filter."""
+ products, total = self.service.get_admin_products(
+ db, marketplace=test_marketplace_product.marketplace
+ )
+
+ assert total >= 1
+ # All products should be from the filtered marketplace
+ for p in products:
+ assert p["marketplace"] == test_marketplace_product.marketplace
+
+ def test_get_admin_products_pagination(self, db, multiple_products):
+ """Test admin products pagination."""
+ # Get first 2
+ products, total = self.service.get_admin_products(db, skip=0, limit=2)
+
+ assert total >= 5 # We created 5 products
+ assert len(products) == 2
+
+ # Get next 2
+ products2, _ = self.service.get_admin_products(db, skip=2, limit=2)
+ assert len(products2) == 2
+
+ # Make sure they're different
+ ids1 = {p["id"] for p in products}
+ ids2 = {p["id"] for p in products2}
+ assert ids1.isdisjoint(ids2)
+
+ def test_get_admin_product_stats(self, db, test_marketplace_product):
+ """Test getting admin product statistics."""
+ stats = self.service.get_admin_product_stats(db)
+
+ assert "total" in stats
+ assert "active" in stats
+ assert "inactive" in stats
+ assert "digital" in stats
+ assert "physical" in stats
+ assert "by_marketplace" in stats
+ assert stats["total"] >= 1
+
+ def test_get_marketplaces_list(self, db, test_marketplace_product):
+ """Test getting list of marketplaces."""
+ marketplaces = self.service.get_marketplaces_list(db)
+
+ assert isinstance(marketplaces, list)
+ assert test_marketplace_product.marketplace in marketplaces
+
+ def test_get_source_vendors_list(self, db, test_marketplace_product):
+ """Test getting list of source vendors."""
+ vendors = self.service.get_source_vendors_list(db)
+
+ assert isinstance(vendors, list)
+ assert test_marketplace_product.vendor_name in vendors
+
+ def test_get_admin_product_detail(self, db, test_marketplace_product):
+ """Test getting admin product detail by ID."""
+ product = self.service.get_admin_product_detail(
+ db, test_marketplace_product.id
+ )
+
+ assert product["id"] == test_marketplace_product.id
+ assert (
+ product["marketplace_product_id"]
+ == test_marketplace_product.marketplace_product_id
+ )
+ assert product["marketplace"] == test_marketplace_product.marketplace
+ assert "translations" in product
+
+ def test_get_admin_product_detail_not_found(self, db):
+ """Test getting non-existent product detail raises exception."""
+ with pytest.raises(MarketplaceProductNotFoundException):
+ self.service.get_admin_product_detail(db, 99999)
+
+ def test_copy_to_vendor_catalog_success(
+ self, db, test_marketplace_product, test_vendor
+ ):
+ """Test copying products to vendor catalog."""
+ result = self.service.copy_to_vendor_catalog(
+ db,
+ marketplace_product_ids=[test_marketplace_product.id],
+ vendor_id=test_vendor.id,
+ )
+
+ assert result["copied"] == 1
+ assert result["skipped"] == 0
+ assert result["failed"] == 0
+
+ def test_copy_to_vendor_catalog_skip_existing(
+ self, db, test_marketplace_product, test_vendor
+ ):
+ """Test copying products that already exist skips them."""
+ # First copy
+ result1 = self.service.copy_to_vendor_catalog(
+ db,
+ marketplace_product_ids=[test_marketplace_product.id],
+ vendor_id=test_vendor.id,
+ )
+ assert result1["copied"] == 1
+
+ # Second copy should skip
+ result2 = self.service.copy_to_vendor_catalog(
+ db,
+ marketplace_product_ids=[test_marketplace_product.id],
+ vendor_id=test_vendor.id,
+ skip_existing=True,
+ )
+ assert result2["copied"] == 0
+ assert result2["skipped"] == 1
+
+ def test_copy_to_vendor_catalog_invalid_vendor(self, db, test_marketplace_product):
+ """Test copying to non-existent vendor raises exception."""
+ from app.exceptions import VendorNotFoundException
+
+ with pytest.raises(VendorNotFoundException):
+ self.service.copy_to_vendor_catalog(
+ db,
+ marketplace_product_ids=[test_marketplace_product.id],
+ vendor_id=99999,
+ )
+
+ def test_copy_to_vendor_catalog_invalid_products(self, db, test_vendor):
+ """Test copying non-existent products raises exception."""
+ with pytest.raises(MarketplaceProductNotFoundException):
+ self.service.copy_to_vendor_catalog(
+ db,
+ marketplace_product_ids=[99999],
+ vendor_id=test_vendor.id,
+ )
diff --git a/tests/unit/services/test_vendor_product_service.py b/tests/unit/services/test_vendor_product_service.py
new file mode 100644
index 00000000..a17f8010
--- /dev/null
+++ b/tests/unit/services/test_vendor_product_service.py
@@ -0,0 +1,126 @@
+# tests/unit/services/test_vendor_product_service.py
+"""
+Unit tests for VendorProductService.
+
+Tests the vendor product catalog service operations.
+"""
+import pytest
+
+from app.exceptions import ProductNotFoundException
+from app.services.vendor_product_service import VendorProductService
+
+
+@pytest.mark.unit
+@pytest.mark.products
+class TestVendorProductService:
+ """Tests for VendorProductService."""
+
+ def setup_method(self):
+ self.service = VendorProductService()
+
+ def test_get_products_success(self, db, test_product):
+ """Test getting vendor products list."""
+ products, total = self.service.get_products(db)
+
+ assert total >= 1
+ assert len(products) >= 1
+
+ # Find our test product in results
+ found = False
+ for p in products:
+ if p["id"] == test_product.id:
+ found = True
+ assert p["vendor_id"] == test_product.vendor_id
+ assert p["marketplace_product_id"] == test_product.marketplace_product_id
+ break
+
+ assert found, "Test product not found in results"
+
+ def test_get_products_with_vendor_filter(self, db, test_product, test_vendor):
+ """Test getting products filtered by vendor."""
+ products, total = self.service.get_products(db, vendor_id=test_vendor.id)
+
+ assert total >= 1
+ # All products should be from the filtered vendor
+ for p in products:
+ assert p["vendor_id"] == test_vendor.id
+
+ def test_get_products_with_active_filter(self, db, test_product):
+ """Test getting products filtered by active status."""
+ products, total = self.service.get_products(db, is_active=True)
+
+ # All products should be active
+ for p in products:
+ assert p["is_active"] is True
+
+ def test_get_products_with_featured_filter(self, db, test_product):
+ """Test getting products filtered by featured status."""
+ products, total = self.service.get_products(db, is_featured=False)
+
+ # All products should not be featured
+ for p in products:
+ assert p["is_featured"] is False
+
+ def test_get_products_pagination(self, db, test_product):
+ """Test vendor products pagination."""
+ products, total = self.service.get_products(db, skip=0, limit=10)
+
+ assert total >= 1
+ assert len(products) <= 10
+
+ def test_get_product_stats_success(self, db, test_product):
+ """Test getting vendor product statistics."""
+ stats = self.service.get_product_stats(db)
+
+ assert "total" in stats
+ assert "active" in stats
+ assert "inactive" in stats
+ assert "featured" in stats
+ assert "digital" in stats
+ assert "physical" in stats
+ assert "by_vendor" in stats
+ assert stats["total"] >= 1
+
+ def test_get_catalog_vendors_success(self, db, test_product, test_vendor):
+ """Test getting list of vendors with products."""
+ vendors = self.service.get_catalog_vendors(db)
+
+ assert isinstance(vendors, list)
+ assert len(vendors) >= 1
+
+ # Check that test_vendor is in the list
+ vendor_ids = [v["id"] for v in vendors]
+ assert test_vendor.id in vendor_ids
+
+ def test_get_product_detail_success(self, db, test_product):
+ """Test getting vendor product detail."""
+ product = self.service.get_product_detail(db, test_product.id)
+
+ assert product["id"] == test_product.id
+ assert product["vendor_id"] == test_product.vendor_id
+ assert product["marketplace_product_id"] == test_product.marketplace_product_id
+ assert "source_marketplace" in product
+ assert "source_vendor" in product
+
+ def test_get_product_detail_not_found(self, db):
+ """Test getting non-existent product raises exception."""
+ with pytest.raises(ProductNotFoundException):
+ self.service.get_product_detail(db, 99999)
+
+ def test_remove_product_success(self, db, test_product):
+ """Test removing product from vendor catalog."""
+ product_id = test_product.id
+
+ result = self.service.remove_product(db, product_id)
+
+ assert "message" in result
+ assert "removed" in result["message"].lower()
+
+ # Verify product is removed
+ with pytest.raises(ProductNotFoundException):
+ self.service.get_product_detail(db, product_id)
+
+ def test_remove_product_not_found(self, db):
+ """Test removing non-existent product raises exception."""
+ with pytest.raises(ProductNotFoundException):
+ self.service.remove_product(db, 99999)