From 9c60989f1d26a237c6f3db60023e9a1b02399d33 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Fri, 12 Dec 2025 22:36:04 +0100 Subject: [PATCH] feat: add marketplace products admin UI with copy-to-vendor functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add admin marketplace products page to browse imported products - Add admin vendor products page to manage vendor catalog - Add product detail pages for both marketplace and vendor products - Implement copy-to-vendor API to copy marketplace products to vendor catalogs - Add vendor product service with CRUD operations - Update sidebar navigation with new product management links - Add integration and unit tests for new endpoints and services 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/api/v1/admin/__init__.py | 13 + app/api/v1/admin/products.py | 252 +++++++++++ app/api/v1/admin/vendor_products.py | 241 ++++++++++ app/exceptions/product.py | 7 +- app/routes/admin_pages.py | 95 ++++ app/services/marketplace_product_service.py | 296 +++++++++++++ app/services/vendor_product_service.py | 266 +++++++++++ .../admin/marketplace-product-detail.html | 333 ++++++++++++++ app/templates/admin/marketplace-products.html | 413 +++++++++++++++++ app/templates/admin/marketplace.html | 44 +- app/templates/admin/partials/sidebar.html | 24 +- .../admin/vendor-product-detail.html | 336 ++++++++++++++ app/templates/admin/vendor-products.html | 330 ++++++++++++++ docs/guides/letzshop-marketplace-api.md | 0 static/admin/js/marketplace-product-detail.js | 181 ++++++++ static/admin/js/marketplace-products.js | 416 ++++++++++++++++++ static/admin/js/marketplace.js | 26 ++ static/admin/js/vendor-product-detail.js | 170 +++++++ static/admin/js/vendor-products.js | 310 +++++++++++++ tests/fixtures/auth_fixtures.py | 25 +- .../api/v1/test_admin_products_endpoints.py | 292 ++++++++++++ .../test_admin_vendor_products_endpoints.py | 221 ++++++++++ tests/integration/api/v1/test_filtering.py | 247 ----------- .../api/v1/test_marketplace_product_export.py | 346 --------------- .../v1/test_marketplace_products_endpoints.py | 395 ----------------- tests/integration/api/v1/test_pagination.py | 362 --------------- tests/unit/services/test_product_service.py | 222 ++++++++-- .../services/test_vendor_product_service.py | 126 ++++++ 28 files changed, 4575 insertions(+), 1414 deletions(-) create mode 100644 app/api/v1/admin/products.py create mode 100644 app/api/v1/admin/vendor_products.py create mode 100644 app/services/vendor_product_service.py create mode 100644 app/templates/admin/marketplace-product-detail.html create mode 100644 app/templates/admin/marketplace-products.html create mode 100644 app/templates/admin/vendor-product-detail.html create mode 100644 app/templates/admin/vendor-products.html create mode 100644 docs/guides/letzshop-marketplace-api.md create mode 100644 static/admin/js/marketplace-product-detail.js create mode 100644 static/admin/js/marketplace-products.js create mode 100644 static/admin/js/vendor-product-detail.js create mode 100644 static/admin/js/vendor-products.js create mode 100644 tests/integration/api/v1/test_admin_products_endpoints.py create mode 100644 tests/integration/api/v1/test_admin_vendor_products_endpoints.py delete mode 100644 tests/integration/api/v1/test_filtering.py delete mode 100644 tests/integration/api/v1/test_marketplace_product_export.py delete mode 100644 tests/integration/api/v1/test_marketplace_products_endpoints.py delete mode 100644 tests/integration/api/v1/test_pagination.py create mode 100644 tests/unit/services/test_vendor_product_service.py 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') }} + + +
+ +
+

+ Quick Actions +

+
+ + + + View Source + +
+
+ + +
+ +
+
+ + +
+ +
+

Additional Images

+
+ +
+
+
+ + +
+ +
+

+ Product Information +

+
+
+

Brand

+

-

+
+
+

Product Type

+
+ + + +
+
+
+

Condition

+

-

+
+
+

Status

+ + +
+
+
+ + +
+

+ Pricing +

+
+
+

Price

+

-

+
+
+

Sale Price

+

-

+
+
+

Availability

+

-

+
+
+
+
+
+ + +
+

+ Product Identifiers +

+
+
+

Marketplace ID

+

-

+
+
+

GTIN/EAN

+

-

+
+
+

MPN

+

-

+
+
+

SKU

+

-

+
+
+
+ + +
+

+ Source Information +

+
+
+

Marketplace

+

-

+
+
+

Source Vendor

+

-

+
+
+

Platform

+

-

+
+
+
+

Source URL

+ - +
+
+ + +
+

+ Categories +

+
+
+

Google Product Category

+

-

+
+
+

Category Path

+

-

+
+
+
+ + +
+

+ Physical Attributes +

+
+
+

Color

+

-

+
+
+

Size

+

-

+
+
+

Weight

+

+ + +

+
+
+
+ + +
+

+ Translations +

+
+ +
+
+ + +
+

+ Record Information +

+
+
+

Created At

+

-

+
+
+

Last Updated

+

-

+
+
+
+
+ + +{% 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 +

+
+
+ + +
+
+ +
+
+

+ Active +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Inactive +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Digital +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Physical +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ + + + +
+
+ + +
+ + + + + + + + + + + + + + +
+
+
+ + +
+
+
+ + product(s) selected + + +
+
+ +
+
+
+ + +
+ {% call table_wrapper() %} + + + + + + Product + Identifiers + Source + Price + Status + Actions + + + + + + + + + + {% 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') }}
@@ -86,19 +95,6 @@

- -
- - -
-
+ {{ 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 %} - -
-
-
- + + {{ 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. +

+
+
+
+ + +
+

+ Quick Actions +

+
+ + + View Source Product + + + + +
+
+ + +
+ +
+
+ + +
+ +
+

Additional Images

+
+ +
+
+
+ + +
+ +
+

+ Vendor Information +

+
+
+

Vendor

+

-

+
+
+

Vendor Code

+

-

+
+
+

Vendor SKU

+
+

-

+ Override +
+
+
+

Status

+
+ + + + Featured + +
+
+
+
+ + +
+

+ Pricing +

+
+
+

Effective Price

+
+

-

+ Override +
+
+
+

Source Price

+

-

+
+
+

Availability

+

-

+
+
+
+
+
+ + +
+

+ Product Information +

+
+
+

Brand

+

-

+
+
+

Product Type

+
+ + +
+
+
+

Condition

+

-

+
+
+
+ + +
+

+ Product Identifiers +

+
+
+

Product ID

+

-

+
+
+

GTIN/EAN

+

-

+
+
+

Vendor SKU

+

-

+
+
+

Source SKU

+

-

+
+
+
+ + +
+
+

+ Source Information +

+ + View Source + + +
+
+
+

Marketplace

+

-

+
+
+

Source Vendor

+

-

+
+
+

Marketplace Product ID

+

-

+
+
+
+ + +
+

+ Product Content +

+
+
+
+

Title

+ Override +
+

-

+
+
+
+

Description

+ Override +
+
+
+
+
+ + +
+

+ Categories +

+
+
+

Google Product Category

+

-

+
+
+

Category Path

+

-

+
+
+
+ + +
+

+ Record Information +

+
+
+

Added to Catalog

+

-

+
+
+

Last Updated

+

-

+
+
+
+
+ + +{% 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 +

+
+
+ + +
+
+ +
+
+

+ Active +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Featured +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Digital +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Physical +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ + + + +
+
+ + +
+ + + + + + + + + + + +
+
+
+ + +
+ {% call table_wrapper() %} + + + Product + Vendor + Source + Price + Status + Actions + + + + + + + + + + {% 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)