# app/services/vendor_service.py """ Vendor service for managing vendor operations and product catalog. This module provides classes and functions for: - Vendor creation and management - Vendor access control and validation - Vendor product catalog operations - Vendor filtering and search """ import logging from sqlalchemy import func from sqlalchemy.orm import Session from app.exceptions import ( InvalidVendorDataException, MarketplaceProductNotFoundException, ProductAlreadyExistsException, UnauthorizedVendorAccessException, ValidationException, VendorAlreadyExistsException, VendorNotFoundException, ) from models.database.marketplace_product import MarketplaceProduct from models.database.product import Product from models.database.user import User from models.database.vendor import Vendor from models.schema.product import ProductCreate from models.schema.vendor import VendorCreate logger = logging.getLogger(__name__) class VendorService: """Service class for vendor operations following the application's service pattern.""" def create_vendor( self, db: Session, vendor_data: VendorCreate, current_user: User ) -> Vendor: """ Create a new vendor under a company. DEPRECATED: This method is for self-service vendor creation by company owners. For admin operations, use admin_service.create_vendor() instead. The new architecture: - Companies are the business entities with owners and contact info - Vendors are storefronts/brands under companies - The company_id is required in vendor_data Args: db: Database session vendor_data: Vendor creation data (must include company_id) current_user: User creating the vendor (must be company owner or admin) Returns: Created vendor object Raises: VendorAlreadyExistsException: If vendor code already exists UnauthorizedVendorAccessException: If user is not company owner InvalidVendorDataException: If vendor data is invalid """ from models.database.company import Company try: # Validate company_id is provided if not hasattr(vendor_data, "company_id") or not vendor_data.company_id: raise InvalidVendorDataException( "company_id is required to create a vendor", field="company_id" ) # Get company and verify ownership company = ( db.query(Company).filter(Company.id == vendor_data.company_id).first() ) if not company: raise InvalidVendorDataException( f"Company with ID {vendor_data.company_id} not found", field="company_id", ) # Check if user is company owner or admin if ( current_user.role != "admin" and company.owner_user_id != current_user.id ): raise UnauthorizedVendorAccessException( f"company-{vendor_data.company_id}", current_user.id ) # Normalize vendor code to uppercase normalized_vendor_code = vendor_data.vendor_code.upper() # Check if vendor code already exists (case-insensitive check) if self._vendor_code_exists(db, normalized_vendor_code): raise VendorAlreadyExistsException(normalized_vendor_code) # Create vendor linked to company new_vendor = Vendor( company_id=company.id, vendor_code=normalized_vendor_code, subdomain=vendor_data.subdomain.lower(), name=vendor_data.name, description=vendor_data.description, letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr, letzshop_csv_url_en=vendor_data.letzshop_csv_url_en, letzshop_csv_url_de=vendor_data.letzshop_csv_url_de, is_active=True, is_verified=(current_user.role == "admin"), ) db.add(new_vendor) db.flush() # Get ID without committing - endpoint handles commit logger.info( f"New vendor created: {new_vendor.vendor_code} under company {company.name} by {current_user.username}" ) return new_vendor except ( VendorAlreadyExistsException, UnauthorizedVendorAccessException, InvalidVendorDataException, ): raise # Re-raise custom exceptions - endpoint handles rollback except Exception as e: logger.error(f"Error creating vendor: {str(e)}") raise ValidationException("Failed to create vendor") def get_vendors( self, db: Session, current_user: User, skip: int = 0, limit: int = 100, active_only: bool = True, verified_only: bool = False, ) -> tuple[list[Vendor], int]: """ Get vendors with filtering. Args: db: Database session current_user: Current user requesting vendors skip: Number of records to skip limit: Maximum number of records to return active_only: Filter for active vendors only verified_only: Filter for verified vendors only Returns: Tuple of (vendors_list, total_count) """ try: query = db.query(Vendor) # Non-admin users can only see active and verified vendors, plus their own if current_user.role != "admin": # Get vendor IDs the user owns through companies from models.database.company import Company owned_vendor_ids = ( db.query(Vendor.id) .join(Company) .filter(Company.owner_user_id == current_user.id) .subquery() ) query = query.filter( (Vendor.is_active == True) & ((Vendor.is_verified == True) | (Vendor.id.in_(owned_vendor_ids))) ) else: # Admin can apply filters if active_only: query = query.filter(Vendor.is_active == True) if verified_only: query = query.filter(Vendor.is_verified == True) total = query.count() vendors = query.offset(skip).limit(limit).all() return vendors, total except Exception as e: logger.error(f"Error getting vendors: {str(e)}") raise ValidationException("Failed to retrieve vendors") def get_vendor_by_code( self, db: Session, vendor_code: str, current_user: User ) -> Vendor: """ Get vendor by vendor code with access control. Args: db: Database session vendor_code: Vendor code to find current_user: Current user requesting the vendor Returns: Vendor object Raises: VendorNotFoundException: If vendor not found UnauthorizedVendorAccessException: If access denied """ try: vendor = ( db.query(Vendor) .filter(func.upper(Vendor.vendor_code) == vendor_code.upper()) .first() ) if not vendor: raise VendorNotFoundException(vendor_code) # Check access permissions if not self._can_access_vendor(vendor, current_user): raise UnauthorizedVendorAccessException(vendor_code, current_user.id) return vendor except (VendorNotFoundException, UnauthorizedVendorAccessException): raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting vendor {vendor_code}: {str(e)}") raise ValidationException("Failed to retrieve vendor") def get_vendor_by_id(self, db: Session, vendor_id: int) -> Vendor: """ Get vendor by ID (admin use - no access control). Args: db: Database session vendor_id: Vendor ID to find Returns: Vendor object with company and owner loaded Raises: VendorNotFoundException: If vendor not found """ from sqlalchemy.orm import joinedload from models.database.company import Company vendor = ( db.query(Vendor) .options(joinedload(Vendor.company).joinedload(Company.owner)) .filter(Vendor.id == vendor_id) .first() ) if not vendor: raise VendorNotFoundException(str(vendor_id), identifier_type="id") return vendor def get_active_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor: """ Get active vendor by vendor_code for public access (no auth required). This method is specifically designed for public endpoints where: - No authentication is required - Only active vendors should be returned - Inactive/disabled vendors are hidden Args: db: Database session vendor_code: Vendor code (case-insensitive) Returns: Vendor object with company and owner loaded Raises: VendorNotFoundException: If vendor not found or inactive """ from sqlalchemy.orm import joinedload from models.database.company import Company vendor = ( db.query(Vendor) .options(joinedload(Vendor.company).joinedload(Company.owner)) .filter( func.upper(Vendor.vendor_code) == vendor_code.upper(), Vendor.is_active == True, ) .first() ) if not vendor: logger.warning(f"Vendor not found or inactive: {vendor_code}") raise VendorNotFoundException(vendor_code, identifier_type="code") return vendor def get_vendor_by_identifier(self, db: Session, identifier: str) -> Vendor: """ Get vendor by ID or vendor_code (admin use - no access control). Args: db: Database session identifier: Either vendor ID (int as string) or vendor_code (string) Returns: Vendor object with company and owner loaded Raises: VendorNotFoundException: If vendor not found """ from sqlalchemy.orm import joinedload from models.database.company import Company # Try as integer ID first try: vendor_id = int(identifier) return self.get_vendor_by_id(db, vendor_id) except (ValueError, TypeError): pass # Not an integer, treat as vendor_code except VendorNotFoundException: pass # ID not found, try as vendor_code # Try as vendor_code (case-insensitive) vendor = ( db.query(Vendor) .options(joinedload(Vendor.company).joinedload(Company.owner)) .filter(func.upper(Vendor.vendor_code) == identifier.upper()) .first() ) if not vendor: raise VendorNotFoundException(identifier, identifier_type="code") return vendor def toggle_verification(self, db: Session, vendor_id: int) -> tuple[Vendor, str]: """ Toggle vendor verification status. Args: db: Database session vendor_id: Vendor ID Returns: Tuple of (updated vendor, status message) Raises: VendorNotFoundException: If vendor not found """ vendor = self.get_vendor_by_id(db, vendor_id) vendor.is_verified = not vendor.is_verified # No commit here - endpoint handles transaction status = "verified" if vendor.is_verified else "unverified" logger.info(f"Vendor {vendor.vendor_code} {status}") return vendor, f"Vendor {vendor.vendor_code} is now {status}" def set_verification( self, db: Session, vendor_id: int, is_verified: bool ) -> tuple[Vendor, str]: """ Set vendor verification status to specific value. Args: db: Database session vendor_id: Vendor ID is_verified: Target verification status Returns: Tuple of (updated vendor, status message) Raises: VendorNotFoundException: If vendor not found """ vendor = self.get_vendor_by_id(db, vendor_id) vendor.is_verified = is_verified # No commit here - endpoint handles transaction status = "verified" if is_verified else "unverified" logger.info(f"Vendor {vendor.vendor_code} set to {status}") return vendor, f"Vendor {vendor.vendor_code} is now {status}" def toggle_status(self, db: Session, vendor_id: int) -> tuple[Vendor, str]: """ Toggle vendor active status. Args: db: Database session vendor_id: Vendor ID Returns: Tuple of (updated vendor, status message) Raises: VendorNotFoundException: If vendor not found """ vendor = self.get_vendor_by_id(db, vendor_id) vendor.is_active = not vendor.is_active # No commit here - endpoint handles transaction status = "active" if vendor.is_active else "inactive" logger.info(f"Vendor {vendor.vendor_code} {status}") return vendor, f"Vendor {vendor.vendor_code} is now {status}" def set_status( self, db: Session, vendor_id: int, is_active: bool ) -> tuple[Vendor, str]: """ Set vendor active status to specific value. Args: db: Database session vendor_id: Vendor ID is_active: Target active status Returns: Tuple of (updated vendor, status message) Raises: VendorNotFoundException: If vendor not found """ vendor = self.get_vendor_by_id(db, vendor_id) vendor.is_active = is_active # No commit here - endpoint handles transaction status = "active" if is_active else "inactive" logger.info(f"Vendor {vendor.vendor_code} set to {status}") return vendor, f"Vendor {vendor.vendor_code} is now {status}" def add_product_to_catalog( self, db: Session, vendor: Vendor, product: ProductCreate ) -> Product: """ Add existing product to vendor catalog with vendor -specific settings. Args: db: Database session vendor : Vendor to add product to product: Vendor product data Returns: Created Product object Raises: MarketplaceProductNotFoundException: If product not found ProductAlreadyExistsException: If product already in vendor """ try: # Check if product exists marketplace_product = self._get_product_by_id_or_raise( db, product.marketplace_product_id ) # Check if product already in vendor if self._product_in_catalog(db, vendor.id, marketplace_product.id): raise ProductAlreadyExistsException( vendor.vendor_code, product.marketplace_product_id ) # Create vendor-product association new_product = Product( vendor_id=vendor.id, marketplace_product_id=marketplace_product.id, **product.model_dump(exclude={"marketplace_product_id"}), ) db.add(new_product) db.flush() # Get ID without committing - endpoint handles commit logger.info( f"MarketplaceProduct {product.marketplace_product_id} added to vendor {vendor.vendor_code}" ) return new_product except (MarketplaceProductNotFoundException, ProductAlreadyExistsException): raise # Re-raise custom exceptions - endpoint handles rollback except Exception as e: logger.error(f"Error adding product to vendor : {str(e)}") raise ValidationException("Failed to add product to vendor ") def get_products( self, db: Session, vendor: Vendor, current_user: User, skip: int = 0, limit: int = 100, active_only: bool = True, featured_only: bool = False, ) -> tuple[list[Product], int]: """ Get products in vendor catalog with filtering. Args: db: Database session vendor : Vendor to get products from current_user: Current user requesting products skip: Number of records to skip limit: Maximum number of records to return active_only: Filter for active products only featured_only: Filter for featured products only Returns: Tuple of (products_list, total_count) Raises: UnauthorizedVendorAccessException: If vendor access denied """ try: # Check access permissions if not self._can_access_vendor(vendor, current_user): raise UnauthorizedVendorAccessException( vendor.vendor_code, current_user.id ) # Query vendor products query = db.query(Product).filter(Product.vendor_id == vendor.id) if active_only: query = query.filter(Product.is_active == True) if featured_only: query = query.filter(Product.is_featured == True) total = query.count() products = query.offset(skip).limit(limit).all() return products, total except UnauthorizedVendorAccessException: raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting vendor products: {str(e)}") raise ValidationException("Failed to retrieve vendor products") # Private helper methods def _vendor_code_exists(self, db: Session, vendor_code: str) -> bool: """Check if vendor code already exists (case-insensitive).""" return ( db.query(Vendor) .filter(func.upper(Vendor.vendor_code) == vendor_code.upper()) .first() is not None ) def _get_product_by_id_or_raise( self, db: Session, marketplace_product_id: int ) -> MarketplaceProduct: """Get marketplace product by database ID or raise exception.""" product = ( db.query(MarketplaceProduct) .filter(MarketplaceProduct.id == marketplace_product_id) .first() ) if not product: raise MarketplaceProductNotFoundException(str(marketplace_product_id)) return product def _product_in_catalog( self, db: Session, vendor_id: int, marketplace_product_id: int ) -> bool: """Check if product is already in vendor.""" return ( db.query(Product) .filter( Product.vendor_id == vendor_id, Product.marketplace_product_id == marketplace_product_id, ) .first() is not None ) def _can_access_vendor(self, vendor: Vendor, user: User) -> bool: """Check if user can access vendor.""" # Admins can always access if user.role == "admin": return True # Company owners can access their vendors if vendor.company and vendor.company.owner_user_id == user.id: return True # Others can only access active and verified vendors return vendor.is_active and vendor.is_verified def _is_vendor_owner(self, vendor: Vendor, user: User) -> bool: """Check if user is vendor owner (via company ownership).""" return vendor.company and vendor.company.owner_user_id == user.id def can_update_vendor(self, vendor: Vendor, user: User) -> bool: """ Check if user has permission to update vendor settings. Permission granted to: - Admins (always) - Vendor owners (company owner) - Team members with appropriate role (owner role in VendorUser) """ # Admins can always update if user.role == "admin": return True # Check if user is vendor owner via company if self._is_vendor_owner(vendor, user): return True # Check if user is owner via VendorUser relationship if user.is_owner_of(vendor.id): return True return False def update_vendor( self, db: Session, vendor_id: int, vendor_update, current_user: User, ) -> "Vendor": """ Update vendor profile with permission checking. Raises: VendorNotFoundException: If vendor not found InsufficientPermissionsException: If user lacks permission """ from app.exceptions import InsufficientPermissionsException vendor = self.get_vendor_by_id(db, vendor_id) # Check permissions in service layer if not self.can_update_vendor(vendor, current_user): raise InsufficientPermissionsException( required_permission="vendor:profile:update" ) # Apply updates update_data = vendor_update.model_dump(exclude_unset=True) for field, value in update_data.items(): if hasattr(vendor, field): setattr(vendor, field, value) db.add(vendor) db.flush() db.refresh(vendor) return vendor def update_marketplace_settings( self, db: Session, vendor_id: int, marketplace_config: dict, current_user: User, ) -> dict: """ Update marketplace integration settings with permission checking. Raises: VendorNotFoundException: If vendor not found InsufficientPermissionsException: If user lacks permission """ from app.exceptions import InsufficientPermissionsException vendor = self.get_vendor_by_id(db, vendor_id) # Check permissions in service layer if not self.can_update_vendor(vendor, current_user): raise InsufficientPermissionsException( required_permission="vendor:settings:update" ) # Update Letzshop URLs if "letzshop_csv_url_fr" in marketplace_config: vendor.letzshop_csv_url_fr = marketplace_config["letzshop_csv_url_fr"] if "letzshop_csv_url_en" in marketplace_config: vendor.letzshop_csv_url_en = marketplace_config["letzshop_csv_url_en"] if "letzshop_csv_url_de" in marketplace_config: vendor.letzshop_csv_url_de = marketplace_config["letzshop_csv_url_de"] db.add(vendor) db.flush() db.refresh(vendor) return { "message": "Marketplace settings updated successfully", "letzshop_csv_url_fr": vendor.letzshop_csv_url_fr, "letzshop_csv_url_en": vendor.letzshop_csv_url_en, "letzshop_csv_url_de": vendor.letzshop_csv_url_de, } # Create service instance following the same pattern as other services vendor_service = VendorService()