# app/services/admin_service.py """ Admin service for managing users, vendors, and import jobs. This module provides classes and functions for: - User management and status control - Vendor creation with owner user generation - Vendor verification and activation - Marketplace import job monitoring - Platform statistics """ import logging import secrets import string from datetime import UTC, datetime from sqlalchemy import func, or_ from sqlalchemy.orm import Session, joinedload from app.exceptions import ( AdminOperationException, CannotModifySelfException, UserCannotBeDeletedException, UserNotFoundException, UserRoleChangeException, UserStatusChangeException, ValidationException, VendorAlreadyExistsException, VendorNotFoundException, VendorVerificationException, ) from app.exceptions.auth import UserAlreadyExistsException from middleware.auth import AuthManager from models.database.company import Company from app.modules.marketplace.models import MarketplaceImportJob from models.database.platform import Platform from models.database.user import User from models.database.vendor import Role, Vendor from app.modules.marketplace.schemas import MarketplaceImportJobResponse from models.schema.vendor import VendorCreate logger = logging.getLogger(__name__) class AdminService: """Service class for admin operations following the application's service pattern.""" # ============================================================================ # USER MANAGEMENT # ============================================================================ def get_all_users(self, db: Session, skip: int = 0, limit: int = 100) -> list[User]: """Get paginated list of all users.""" try: return db.query(User).offset(skip).limit(limit).all() except Exception as e: logger.error(f"Failed to retrieve users: {str(e)}") raise AdminOperationException( operation="get_all_users", reason="Database query failed" ) def toggle_user_status( self, db: Session, user_id: int, current_admin_id: int ) -> tuple[User, str]: """Toggle user active status.""" user = self._get_user_by_id_or_raise(db, user_id) # Prevent self-modification if user.id == current_admin_id: raise CannotModifySelfException(user_id, "deactivate account") # Check if user is another admin if user.role == "admin" and user.id != current_admin_id: raise UserStatusChangeException( user_id=user_id, current_status="admin", attempted_action="toggle status", reason="Cannot modify another admin user", ) try: original_status = user.is_active user.is_active = not user.is_active user.updated_at = datetime.now(UTC) db.flush() db.refresh(user) status_action = "activated" if user.is_active else "deactivated" message = f"User {user.username} has been {status_action}" logger.info(f"{message} by admin {current_admin_id}") return user, message except Exception as e: logger.error(f"Failed to toggle user {user_id} status: {str(e)}") raise UserStatusChangeException( user_id=user_id, current_status="active" if original_status else "inactive", attempted_action="toggle status", reason="Database update failed", ) def list_users( self, db: Session, page: int = 1, per_page: int = 10, search: str | None = None, role: str | None = None, is_active: bool | None = None, ) -> tuple[list[User], int, int]: """ Get paginated list of users with filtering. Returns: Tuple of (users, total_count, total_pages) """ import math query = db.query(User) # Apply filters if search: search_term = f"%{search.lower()}%" query = query.filter( or_( User.username.ilike(search_term), User.email.ilike(search_term), User.first_name.ilike(search_term), User.last_name.ilike(search_term), ) ) if role: query = query.filter(User.role == role) if is_active is not None: query = query.filter(User.is_active == is_active) # Get total count total = query.count() pages = math.ceil(total / per_page) if total > 0 else 1 # Apply pagination skip = (page - 1) * per_page users = ( query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all() ) return users, total, pages def create_user( self, db: Session, email: str, username: str, password: str, first_name: str | None = None, last_name: str | None = None, role: str = "customer", current_admin_id: int | None = None, ) -> User: """ Create a new user. Raises: UserAlreadyExistsException: If email or username already exists """ # Check if email exists if db.query(User).filter(User.email == email).first(): raise UserAlreadyExistsException("Email already registered", field="email") # Check if username exists if db.query(User).filter(User.username == username).first(): raise UserAlreadyExistsException("Username already taken", field="username") # Create user auth_manager = AuthManager() user = User( email=email, username=username, hashed_password=auth_manager.hash_password(password), first_name=first_name, last_name=last_name, role=role, is_active=True, ) db.add(user) db.flush() db.refresh(user) logger.info(f"Admin {current_admin_id} created user {user.username}") return user def get_user_details(self, db: Session, user_id: int) -> User: """ Get user with relationships loaded. Raises: UserNotFoundException: If user not found """ user = ( db.query(User) .options( joinedload(User.owned_companies), joinedload(User.vendor_memberships) ) .filter(User.id == user_id) .first() ) if not user: raise UserNotFoundException(str(user_id)) return user def update_user( self, db: Session, user_id: int, current_admin_id: int, email: str | None = None, username: str | None = None, first_name: str | None = None, last_name: str | None = None, role: str | None = None, is_active: bool | None = None, ) -> User: """ Update user information. Raises: UserNotFoundException: If user not found UserAlreadyExistsException: If email/username already taken UserRoleChangeException: If trying to change own admin role """ user = self._get_user_by_id_or_raise(db, user_id) # Prevent changing own admin status if user.id == current_admin_id and role and role != "admin": raise UserRoleChangeException( user_id=user_id, current_role=user.role, target_role=role, reason="Cannot change your own admin role", ) # Check email uniqueness if changing if email and email != user.email: if db.query(User).filter(User.email == email).first(): raise UserAlreadyExistsException( "Email already registered", field="email" ) # Check username uniqueness if changing if username and username != user.username: if db.query(User).filter(User.username == username).first(): raise UserAlreadyExistsException( "Username already taken", field="username" ) # Update fields if email is not None: user.email = email if username is not None: user.username = username if first_name is not None: user.first_name = first_name if last_name is not None: user.last_name = last_name if role is not None: user.role = role if is_active is not None: user.is_active = is_active user.updated_at = datetime.now(UTC) db.flush() db.refresh(user) logger.info(f"Admin {current_admin_id} updated user {user.username}") return user def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str: """ Delete a user. Raises: UserNotFoundException: If user not found CannotModifySelfException: If trying to delete yourself UserCannotBeDeletedException: If user owns companies """ user = ( db.query(User) .options(joinedload(User.owned_companies)) .filter(User.id == user_id) .first() ) if not user: raise UserNotFoundException(str(user_id)) # Prevent deleting yourself if user.id == current_admin_id: raise CannotModifySelfException(user_id, "delete account") # Prevent deleting users who own companies if user.owned_companies: raise UserCannotBeDeletedException( user_id=user_id, reason=f"User owns {len(user.owned_companies)} company(ies). Transfer ownership first.", owned_count=len(user.owned_companies), ) username = user.username db.delete(user) logger.info(f"Admin {current_admin_id} deleted user {username}") return f"User {username} deleted successfully" def search_users( self, db: Session, query: str, limit: int = 10, ) -> list[dict]: """ Search users by username or email. Used for autocomplete in ownership transfer. """ search_term = f"%{query.lower()}%" users = ( db.query(User) .filter( or_(User.username.ilike(search_term), User.email.ilike(search_term)) ) .limit(limit) .all() ) return [ { "id": user.id, "username": user.username, "email": user.email, "is_active": user.is_active, } for user in users ] # ============================================================================ # VENDOR MANAGEMENT # ============================================================================ def create_vendor(self, db: Session, vendor_data: VendorCreate) -> Vendor: """ Create a vendor (storefront/brand) under an existing company. The vendor inherits owner and contact information from its parent company. Args: db: Database session vendor_data: Vendor creation data including company_id Returns: The created Vendor object with company relationship loaded Raises: ValidationException: If company not found or vendor code/subdomain exists AdminOperationException: If creation fails """ try: # Validate company exists company = ( db.query(Company).filter(Company.id == vendor_data.company_id).first() ) if not company: raise ValidationException( f"Company with ID {vendor_data.company_id} not found" ) # Check if vendor code already exists existing_vendor = ( db.query(Vendor) .filter( func.upper(Vendor.vendor_code) == vendor_data.vendor_code.upper() ) .first() ) if existing_vendor: raise VendorAlreadyExistsException(vendor_data.vendor_code) # Check if subdomain already exists existing_subdomain = ( db.query(Vendor) .filter(func.lower(Vendor.subdomain) == vendor_data.subdomain.lower()) .first() ) if existing_subdomain: raise ValidationException( f"Subdomain '{vendor_data.subdomain}' is already taken" ) # Create vendor linked to company vendor = Vendor( company_id=company.id, vendor_code=vendor_data.vendor_code.upper(), 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=False, # Needs verification by admin ) db.add(vendor) db.flush() # Get vendor.id # Create default roles for vendor self._create_default_roles(db, vendor.id) # Assign vendor to platforms if provided if vendor_data.platform_ids: from models.database.vendor_platform import VendorPlatform for platform_id in vendor_data.platform_ids: # Verify platform exists platform = db.query(Platform).filter(Platform.id == platform_id).first() if platform: vendor_platform = VendorPlatform( vendor_id=vendor.id, platform_id=platform_id, is_active=True, ) db.add(vendor_platform) logger.debug( f"Assigned vendor {vendor.vendor_code} to platform {platform.code}" ) db.flush() db.refresh(vendor) logger.info( f"Vendor {vendor.vendor_code} created under company {company.name} (ID: {company.id})" ) return vendor except (VendorAlreadyExistsException, ValidationException): raise except Exception as e: logger.error(f"Failed to create vendor: {str(e)}") raise AdminOperationException( operation="create_vendor", reason=f"Failed to create vendor: {str(e)}", ) def get_all_vendors( self, db: Session, skip: int = 0, limit: int = 100, search: str | None = None, is_active: bool | None = None, is_verified: bool | None = None, ) -> tuple[list[Vendor], int]: """Get paginated list of all vendors with filtering.""" try: # Eagerly load company relationship to avoid N+1 queries query = db.query(Vendor).options(joinedload(Vendor.company)) # Apply search filter if search: search_term = f"%{search}%" query = query.filter( or_( Vendor.name.ilike(search_term), Vendor.vendor_code.ilike(search_term), Vendor.subdomain.ilike(search_term), ) ) # Apply status filters if is_active is not None: query = query.filter(Vendor.is_active == is_active) if is_verified is not None: query = query.filter(Vendor.is_verified == is_verified) # Get total count (without joinedload for performance) count_query = db.query(Vendor) if search: search_term = f"%{search}%" count_query = count_query.filter( or_( Vendor.name.ilike(search_term), Vendor.vendor_code.ilike(search_term), Vendor.subdomain.ilike(search_term), ) ) if is_active is not None: count_query = count_query.filter(Vendor.is_active == is_active) if is_verified is not None: count_query = count_query.filter(Vendor.is_verified == is_verified) total = count_query.count() # Get paginated results vendors = query.offset(skip).limit(limit).all() return vendors, total except Exception as e: logger.error(f"Failed to retrieve vendors: {str(e)}") raise AdminOperationException( operation="get_all_vendors", reason="Database query failed" ) def get_vendor_by_id(self, db: Session, vendor_id: int) -> Vendor: """Get vendor by ID.""" return self._get_vendor_by_id_or_raise(db, vendor_id) def verify_vendor(self, db: Session, vendor_id: int) -> tuple[Vendor, str]: """Toggle vendor verification status.""" vendor = self._get_vendor_by_id_or_raise(db, vendor_id) try: original_status = vendor.is_verified vendor.is_verified = not vendor.is_verified vendor.updated_at = datetime.now(UTC) if vendor.is_verified: vendor.verified_at = datetime.now(UTC) db.flush() db.refresh(vendor) status_action = "verified" if vendor.is_verified else "unverified" message = f"Vendor {vendor.vendor_code} has been {status_action}" logger.info(message) return vendor, message except Exception as e: logger.error(f"Failed to verify vendor {vendor_id}: {str(e)}") raise VendorVerificationException( vendor_id=vendor_id, reason="Database update failed", current_verification_status=original_status, ) def toggle_vendor_status(self, db: Session, vendor_id: int) -> tuple[Vendor, str]: """Toggle vendor active status.""" vendor = self._get_vendor_by_id_or_raise(db, vendor_id) try: original_status = vendor.is_active vendor.is_active = not vendor.is_active vendor.updated_at = datetime.now(UTC) db.flush() db.refresh(vendor) status_action = "activated" if vendor.is_active else "deactivated" message = f"Vendor {vendor.vendor_code} has been {status_action}" logger.info(message) return vendor, message except Exception as e: logger.error(f"Failed to toggle vendor {vendor_id} status: {str(e)}") raise AdminOperationException( operation="toggle_vendor_status", reason="Database update failed", target_type="vendor", target_id=str(vendor_id), ) def delete_vendor(self, db: Session, vendor_id: int) -> str: """Delete vendor and all associated data.""" vendor = self._get_vendor_by_id_or_raise(db, vendor_id) try: vendor_code = vendor.vendor_code # TODO: Delete associated data in correct order # - Delete orders # - Delete customers # - Delete products # - Delete team members # - Delete roles # - Delete import jobs db.delete(vendor) logger.warning(f"Vendor {vendor_code} and all associated data deleted") return f"Vendor {vendor_code} successfully deleted" except Exception as e: logger.error(f"Failed to delete vendor {vendor_id}: {str(e)}") raise AdminOperationException( operation="delete_vendor", reason="Database deletion failed" ) def update_vendor( self, db: Session, vendor_id: int, vendor_update, # VendorUpdate schema ) -> Vendor: """ Update vendor information (Admin only). Can update: - Vendor details (name, description, subdomain) - Business contact info (contact_email, phone, etc.) - Status (is_active, is_verified) Cannot update: - vendor_code (immutable) - company_id (vendor cannot be moved between companies) Note: Ownership is managed at the Company level. Use company_service.transfer_ownership() for ownership changes. Args: db: Database session vendor_id: ID of vendor to update vendor_update: VendorUpdate schema with updated data Returns: Updated vendor object Raises: VendorNotFoundException: If vendor not found ValidationException: If subdomain already taken """ vendor = self._get_vendor_by_id_or_raise(db, vendor_id) try: # Get update data update_data = vendor_update.model_dump(exclude_unset=True) # Handle reset_contact_to_company flag if update_data.pop("reset_contact_to_company", False): # Reset all contact fields to None (inherit from company) update_data["contact_email"] = None update_data["contact_phone"] = None update_data["website"] = None update_data["business_address"] = None update_data["tax_number"] = None # Convert empty strings to None for contact fields (empty = inherit) contact_fields = [ "contact_email", "contact_phone", "website", "business_address", "tax_number", ] for field in contact_fields: if field in update_data and update_data[field] == "": update_data[field] = None # Check subdomain uniqueness if changing if ( "subdomain" in update_data and update_data["subdomain"] != vendor.subdomain ): existing = ( db.query(Vendor) .filter( Vendor.subdomain == update_data["subdomain"], Vendor.id != vendor_id, ) .first() ) if existing: raise ValidationException( f"Subdomain '{update_data['subdomain']}' is already taken" ) # Update vendor fields for field, value in update_data.items(): setattr(vendor, field, value) vendor.updated_at = datetime.now(UTC) db.flush() db.refresh(vendor) logger.info( f"Vendor {vendor_id} ({vendor.vendor_code}) updated by admin. " f"Fields updated: {', '.join(update_data.keys())}" ) return vendor except ValidationException: raise except Exception as e: logger.error(f"Failed to update vendor {vendor_id}: {str(e)}") raise AdminOperationException( operation="update_vendor", reason=f"Database update failed: {str(e)}" ) # NOTE: Vendor ownership transfer is now handled at the Company level. # Use company_service.transfer_ownership() instead. # ============================================================================ # MARKETPLACE IMPORT JOBS # ============================================================================ def get_marketplace_import_jobs( self, db: Session, marketplace: str | None = None, vendor_name: str | None = None, status: str | None = None, skip: int = 0, limit: int = 100, ) -> list[MarketplaceImportJobResponse]: """Get filtered and paginated marketplace import jobs.""" try: query = db.query(MarketplaceImportJob) if marketplace: query = query.filter( MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%") ) if vendor_name: query = query.filter( MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%") ) if status: query = query.filter(MarketplaceImportJob.status == status) jobs = ( query.order_by(MarketplaceImportJob.created_at.desc()) .offset(skip) .limit(limit) .all() ) return [self._convert_job_to_response(job) for job in jobs] except Exception as e: logger.error(f"Failed to retrieve marketplace import jobs: {str(e)}") raise AdminOperationException( operation="get_marketplace_import_jobs", reason="Database query failed" ) # ============================================================================ # STATISTICS # ============================================================================ def get_recent_vendors(self, db: Session, limit: int = 5) -> list[dict]: """Get recently created vendors.""" try: vendors = ( db.query(Vendor).order_by(Vendor.created_at.desc()).limit(limit).all() ) return [ { "id": v.id, "vendor_code": v.vendor_code, "name": v.name, "subdomain": v.subdomain, "is_active": v.is_active, "is_verified": v.is_verified, "created_at": v.created_at, } for v in vendors ] except Exception as e: logger.error(f"Failed to get recent vendors: {str(e)}") return [] def get_recent_import_jobs(self, db: Session, limit: int = 10) -> list[dict]: """Get recent marketplace import jobs.""" try: jobs = ( db.query(MarketplaceImportJob) .order_by(MarketplaceImportJob.created_at.desc()) .limit(limit) .all() ) return [ { "id": j.id, "marketplace": j.marketplace, "vendor_name": j.vendor_name, "status": j.status, "total_processed": j.total_processed or 0, "created_at": j.created_at, } for j in jobs ] except Exception as e: logger.error(f"Failed to get recent import jobs: {str(e)}") return [] # ============================================================================ # PRIVATE HELPER METHODS # ============================================================================ def _get_user_by_id_or_raise(self, db: Session, user_id: int) -> User: """Get user by ID or raise UserNotFoundException.""" user = db.query(User).filter(User.id == user_id).first() if not user: raise UserNotFoundException(str(user_id)) return user def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor: """Get vendor by ID or raise VendorNotFoundException.""" 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 _generate_temp_password(self, length: int = 12) -> str: """Generate secure temporary password.""" alphabet = string.ascii_letters + string.digits + "!@#$%^&*" return "".join(secrets.choice(alphabet) for _ in range(length)) def _create_default_roles(self, db: Session, vendor_id: int): """Create default roles for a new vendor.""" default_roles = [ {"name": "Owner", "permissions": ["*"]}, # Full access { "name": "Manager", "permissions": [ "products.*", "orders.*", "customers.view", "inventory.*", "team.view", ], }, { "name": "Editor", "permissions": [ "products.view", "products.edit", "orders.view", "inventory.view", ], }, { "name": "Viewer", "permissions": [ "products.view", "orders.view", "customers.view", "inventory.view", ], }, ] for role_data in default_roles: role = Role( vendor_id=vendor_id, name=role_data["name"], permissions=role_data["permissions"], ) db.add(role) def _convert_job_to_response( self, job: MarketplaceImportJob ) -> MarketplaceImportJobResponse: """Convert database model to response schema.""" return MarketplaceImportJobResponse( job_id=job.id, status=job.status, marketplace=job.marketplace, source_url=job.source_url, vendor_id=job.vendor.id if job.vendor else None, vendor_code=job.vendor.vendor_code if job.vendor else None, vendor_name=job.vendor.name if job.vendor else None, 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, ) # Create service instance admin_service = AdminService()