# app/services/marketplace_service.py """ Marketplace service for managing import jobs and marketplace integrations. This module provides classes and functions for: - Import job creation and management - Shop access validation - Import job status tracking and updates """ import logging from datetime import datetime, timezone from typing import List, Optional from sqlalchemy import func from sqlalchemy.orm import Session from app.exceptions import ( ShopNotFoundException, UnauthorizedShopAccessException, ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeCancelledException, ImportJobCannotBeDeletedException, ValidationException, ) from models.schemas.marketplace import (MarketplaceImportJobResponse, MarketplaceImportRequest) from models.database.marketplace import MarketplaceImportJob from models.database.shop import Shop from models.database.user import User logger = logging.getLogger(__name__) class MarketplaceService: """Service class for Marketplace operations following the application's service pattern.""" def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop: """ Validate that the shop exists and user has access to it. Args: db: Database session shop_code: Shop code to validate user: User requesting access Returns: Shop object if access is valid Raises: ShopNotFoundException: If shop doesn't exist UnauthorizedShopAccessException: If user lacks access """ try: # Use case-insensitive query to handle both uppercase and lowercase codes shop = ( db.query(Shop) .filter(func.upper(Shop.shop_code) == shop_code.upper()) .first() ) if not shop: raise ShopNotFoundException(shop_code) # Check permissions: admin can import for any shop, others only for their own if user.role != "admin" and shop.owner_id != user.id: raise UnauthorizedShopAccessException(shop_code, user.id) return shop except (ShopNotFoundException, UnauthorizedShopAccessException): raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error validating shop access: {str(e)}") raise ValidationException("Failed to validate shop access") def create_import_job( self, db: Session, request: MarketplaceImportRequest, user: User ) -> MarketplaceImportJob: """ Create a new marketplace import job. Args: db: Database session request: Import request data user: User creating the job Returns: Created MarketplaceImportJob object Raises: ShopNotFoundException: If shop doesn't exist UnauthorizedShopAccessException: If user lacks shop access ValidationException: If job creation fails """ try: # Validate shop access first shop = self.validate_shop_access(db, request.shop_code, user) # Create marketplace import job record import_job = MarketplaceImportJob( status="pending", source_url=request.url, marketplace=request.marketplace, shop_id=shop.id, # Foreign key to shops table shop_name=shop.shop_name, # Use shop.shop_name (the display name) user_id=user.id, created_at=datetime.now(timezone.utc), ) db.add(import_job) db.commit() db.refresh(import_job) logger.info( f"Created marketplace import job {import_job.id}: " f"{request.marketplace} -> {shop.shop_name} (shop_code: {shop.shop_code}) by user {user.username}" ) return import_job except (ShopNotFoundException, UnauthorizedShopAccessException): raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error creating import job: {str(e)}") raise ValidationException("Failed to create import job") def get_import_job_by_id( self, db: Session, job_id: int, user: User ) -> MarketplaceImportJob: """ Get a marketplace import job by ID with access control. Args: db: Database session job_id: Import job ID user: User requesting the job Returns: MarketplaceImportJob object Raises: ImportJobNotFoundException: If job doesn't exist ImportJobNotOwnedException: If user lacks access to job """ try: job = ( db.query(MarketplaceImportJob) .filter(MarketplaceImportJob.id == job_id) .first() ) if not job: raise ImportJobNotFoundException(job_id) # Users can only see their own jobs, admins can see all if user.role != "admin" and job.user_id != user.id: raise ImportJobNotOwnedException(job_id, user.id) return job except (ImportJobNotFoundException, ImportJobNotOwnedException): raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting import job {job_id}: {str(e)}") raise ValidationException("Failed to retrieve import job") def get_import_jobs( self, db: Session, user: User, marketplace: Optional[str] = None, shop_name: Optional[str] = None, skip: int = 0, limit: int = 50, ) -> List[MarketplaceImportJob]: """ Get marketplace import jobs with filtering and access control. Args: db: Database session user: User requesting jobs marketplace: Optional marketplace filter shop_name: Optional shop name filter skip: Number of records to skip limit: Maximum records to return Returns: List of MarketplaceImportJob objects """ try: query = db.query(MarketplaceImportJob) # Users can only see their own jobs, admins can see all if user.role != "admin": query = query.filter(MarketplaceImportJob.user_id == user.id) # Apply filters if marketplace: query = query.filter( MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%") ) if shop_name: query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%")) # Order by creation date (newest first) and apply pagination jobs = ( query.order_by(MarketplaceImportJob.created_at.desc()) .offset(skip) .limit(limit) .all() ) return jobs except Exception as e: logger.error(f"Error getting import jobs: {str(e)}") raise ValidationException("Failed to retrieve import jobs") def update_job_status( self, db: Session, job_id: int, status: str, **kwargs ) -> MarketplaceImportJob: """ Update marketplace import job status and other fields. Args: db: Database session job_id: Import job ID status: New status **kwargs: Additional fields to update Returns: Updated MarketplaceImportJob object Raises: ImportJobNotFoundException: If job doesn't exist ValidationException: If update fails """ try: job = ( db.query(MarketplaceImportJob) .filter(MarketplaceImportJob.id == job_id) .first() ) if not job: raise ImportJobNotFoundException(job_id) job.status = status # Update optional fields if provided allowed_fields = [ 'imported_count', 'updated_count', 'total_processed', 'error_count', 'error_message', 'started_at', 'completed_at' ] for field in allowed_fields: if field in kwargs: setattr(job, field, kwargs[field]) db.commit() db.refresh(job) logger.info(f"Updated marketplace import job {job_id} status to {status}") return job except ImportJobNotFoundException: raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error updating job {job_id} status: {str(e)}") raise ValidationException("Failed to update job status") def get_job_stats(self, db: Session, user: User) -> dict: """ Get statistics about marketplace import jobs for a user. Args: db: Database session user: User to get stats for Returns: Dictionary containing job statistics """ try: query = db.query(MarketplaceImportJob) # Users can only see their own jobs, admins can see all if user.role != "admin": query = query.filter(MarketplaceImportJob.user_id == user.id) total_jobs = query.count() pending_jobs = query.filter(MarketplaceImportJob.status == "pending").count() running_jobs = query.filter(MarketplaceImportJob.status == "running").count() completed_jobs = query.filter( MarketplaceImportJob.status == "completed" ).count() failed_jobs = query.filter(MarketplaceImportJob.status == "failed").count() return { "total_jobs": total_jobs, "pending_jobs": pending_jobs, "running_jobs": running_jobs, "completed_jobs": completed_jobs, "failed_jobs": failed_jobs, } except Exception as e: logger.error(f"Error getting job stats: {str(e)}") raise ValidationException("Failed to retrieve job statistics") def convert_to_response_model( self, job: MarketplaceImportJob ) -> MarketplaceImportJobResponse: """Convert database model to API response model.""" return MarketplaceImportJobResponse( job_id=job.id, status=job.status, marketplace=job.marketplace, shop_id=job.shop_id, shop_code=( job.shop.shop_code if job.shop else None ), # Add this optional field via relationship shop_name=job.shop_name, imported=job.imported_count or 0, updated=job.updated_count or 0, total_processed=job.total_processed or 0, error_count=job.error_count or 0, error_message=job.error_message, created_at=job.created_at, started_at=job.started_at, completed_at=job.completed_at, ) def cancel_import_job( self, db: Session, job_id: int, user: User ) -> MarketplaceImportJob: """ Cancel a pending or running import job. Args: db: Database session job_id: Import job ID user: User requesting cancellation Returns: Updated MarketplaceImportJob object Raises: ImportJobNotFoundException: If job doesn't exist ImportJobNotOwnedException: If user lacks access ImportJobCannotBeCancelledException: If job can't be cancelled """ try: job = self.get_import_job_by_id(db, job_id, user) if job.status not in ["pending", "running"]: raise ImportJobCannotBeCancelledException(job_id, job.status) job.status = "cancelled" job.completed_at = datetime.now(timezone.utc) db.commit() db.refresh(job) logger.info(f"Cancelled marketplace import job {job_id}") return job except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeCancelledException): raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error cancelling job {job_id}: {str(e)}") raise ValidationException("Failed to cancel import job") def delete_import_job(self, db: Session, job_id: int, user: User) -> bool: """ Delete a marketplace import job. Args: db: Database session job_id: Import job ID user: User requesting deletion Returns: True if deletion successful Raises: ImportJobNotFoundException: If job doesn't exist ImportJobNotOwnedException: If user lacks access ImportJobCannotBeDeletedException: If job can't be deleted """ try: job = self.get_import_job_by_id(db, job_id, user) # Only allow deletion of completed, failed, or cancelled jobs if job.status in ["pending", "running"]: raise ImportJobCannotBeDeletedException(job_id, job.status) db.delete(job) db.commit() logger.info(f"Deleted marketplace import job {job_id}") return True except (ImportJobNotFoundException, ImportJobNotOwnedException, ImportJobCannotBeDeletedException): raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error deleting job {job_id}: {str(e)}") raise ValidationException("Failed to delete import job") # Create service instance marketplace_service = MarketplaceService()