# 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 datetime, timezone from typing import List, Optional, Tuple from sqlalchemy.orm import Session from sqlalchemy import func, or_ from app.exceptions import ( UserNotFoundException, UserStatusChangeException, CannotModifySelfException, VendorNotFoundException, VendorAlreadyExistsException, VendorVerificationException, AdminOperationException, ValidationException, ) from models.schema.marketplace_import_job import MarketplaceImportJobResponse from models.schema.vendor import VendorCreate from models.database.marketplace_import_job import MarketplaceImportJob from models.database.vendor import Vendor, Role, VendorUser from models.database.user import User 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(timezone.utc) db.commit() 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: db.rollback() 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" ) # ============================================================================ # VENDOR MANAGEMENT # ============================================================================ def create_vendor_with_owner( self, db: Session, vendor_data: VendorCreate ) -> Tuple[Vendor, User, str]: """ Create vendor with owner user account. Creates: 1. User account with owner_email (for authentication) 2. Vendor with contact_email (for business contact) If contact_email not provided, defaults to owner_email. Returns: (vendor, owner_user, temporary_password) """ try: # 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" ) # Generate temporary password for owner temp_password = self._generate_temp_password() # Create owner user with owner_email from middleware.auth import AuthManager auth_manager = AuthManager() owner_username = f"{vendor_data.subdomain}_owner" owner_email = vendor_data.owner_email # ✅ For User authentication # Check if user with this email already exists existing_user = db.query(User).filter( User.email == owner_email ).first() if existing_user: # Use existing user as owner owner_user = existing_user else: # Create new owner user owner_user = User( email=owner_email, # ✅ Authentication email username=owner_username, hashed_password=auth_manager.hash_password(temp_password), role="user", is_active=True, ) db.add(owner_user) db.flush() # Get owner_user.id # Determine contact_email # If provided, use it; otherwise default to owner_email contact_email = vendor_data.contact_email or owner_email # Create vendor vendor = Vendor( vendor_code=vendor_data.vendor_code.upper(), subdomain=vendor_data.subdomain.lower(), name=vendor_data.name, description=vendor_data.description, owner_user_id=owner_user.id, contact_email=contact_email, # ✅ Business contact email contact_phone=vendor_data.contact_phone, website=vendor_data.website, business_address=vendor_data.business_address, tax_number=vendor_data.tax_number, 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=True, ) db.add(vendor) db.flush() # Get vendor.id # Create default roles for vendor self._create_default_roles(db, vendor.id) db.commit() db.refresh(vendor) db.refresh(owner_user) logger.info( f"Vendor {vendor.vendor_code} created with owner {owner_user.username} " f"(owner_email: {owner_email}, contact_email: {contact_email})" ) # TODO: Send welcome email to owner with credentials # self._send_vendor_welcome_email(owner_user, vendor, temp_password) return vendor, owner_user, temp_password except (VendorAlreadyExistsException, ValidationException): db.rollback() raise except Exception as e: db.rollback() logger.error(f"Failed to create vendor: {str(e)}") raise AdminOperationException( operation="create_vendor_with_owner", reason=f"Failed to create vendor: {str(e)}" ) def get_all_vendors( self, db: Session, skip: int = 0, limit: int = 100, search: Optional[str] = None, is_active: Optional[bool] = None, is_verified: Optional[bool] = None ) -> Tuple[List[Vendor], int]: """Get paginated list of all vendors with filtering.""" try: query = db.query(Vendor) # 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) total = query.count() 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(timezone.utc) if vendor.is_verified: vendor.verified_at = datetime.now(timezone.utc) db.commit() 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: db.rollback() 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(timezone.utc) db.commit() 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: db.rollback() 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) db.commit() logger.warning(f"Vendor {vendor_code} and all associated data deleted") return f"Vendor {vendor_code} successfully deleted" except Exception as e: db.rollback() 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: - owner_email (use transfer_vendor_ownership instead) - vendor_code (immutable) - owner_user_id (use transfer_vendor_ownership instead) 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) # 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(timezone.utc) db.commit() 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: db.rollback() raise except Exception as e: db.rollback() logger.error(f"Failed to update vendor {vendor_id}: {str(e)}") raise AdminOperationException( operation="update_vendor", reason=f"Database update failed: {str(e)}" ) # Add this NEW method for transferring ownership: def transfer_vendor_ownership( self, db: Session, vendor_id: int, transfer_data # VendorTransferOwnership schema ) -> Tuple[Vendor, User, User]: """ Transfer vendor ownership to another user. This method: 1. Validates new owner exists and is active 2. Removes old owner from "Owner" role (demotes to Manager) 3. Assigns new owner to "Owner" role 4. Updates vendor.owner_user_id 5. Creates audit log entry Args: db: Database session vendor_id: ID of vendor transfer_data: Transfer details (new owner ID, confirmation, reason) Returns: Tuple of (vendor, old_owner, new_owner) Raises: VendorNotFoundException: If vendor not found UserNotFoundException: If new owner user not found ValidationException: If confirmation not provided or user already owner """ # Require confirmation if not transfer_data.confirm_transfer: raise ValidationException( "Ownership transfer requires confirmation (confirm_transfer=true)" ) # Get vendor vendor = self._get_vendor_by_id_or_raise(db, vendor_id) old_owner = vendor.owner # Get new owner new_owner = db.query(User).filter( User.id == transfer_data.new_owner_user_id ).first() if not new_owner: raise UserNotFoundException(str(transfer_data.new_owner_user_id)) # Check if new owner is active if not new_owner.is_active: raise ValidationException( f"User {new_owner.username} (ID: {new_owner.id}) is not active" ) # Check if already owner if new_owner.id == old_owner.id: raise ValidationException( f"User {new_owner.username} is already the owner of this vendor" ) try: # Get Owner role for this vendor owner_role = db.query(Role).filter( Role.vendor_id == vendor_id, Role.name == "Owner" ).first() if not owner_role: raise ValidationException("Owner role not found for vendor") # Get Manager role (to demote old owner) manager_role = db.query(Role).filter( Role.vendor_id == vendor_id, Role.name == "Manager" ).first() # Remove old owner from Owner role old_owner_link = db.query(VendorUser).filter( VendorUser.vendor_id == vendor_id, VendorUser.user_id == old_owner.id, VendorUser.role_id == owner_role.id ).first() if old_owner_link: if manager_role: # Demote to Manager role old_owner_link.role_id = manager_role.id logger.info( f"Old owner {old_owner.username} demoted to Manager role " f"for vendor {vendor.vendor_code}" ) else: # No Manager role, just remove Owner link db.delete(old_owner_link) logger.warning( f"Old owner {old_owner.username} removed from vendor {vendor.vendor_code} " f"(no Manager role available)" ) # Check if new owner already has a vendor_user link new_owner_link = db.query(VendorUser).filter( VendorUser.vendor_id == vendor_id, VendorUser.user_id == new_owner.id ).first() if new_owner_link: # Update existing link to Owner role new_owner_link.role_id = owner_role.id new_owner_link.is_active = True else: # Create new Owner link new_owner_link = VendorUser( vendor_id=vendor_id, user_id=new_owner.id, role_id=owner_role.id, is_active=True ) db.add(new_owner_link) # Update vendor owner_user_id vendor.owner_user_id = new_owner.id vendor.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(vendor) logger.warning( f"OWNERSHIP TRANSFERRED for vendor {vendor.vendor_code}: " f"{old_owner.username} (ID: {old_owner.id}) -> " f"{new_owner.username} (ID: {new_owner.id}). " f"Reason: {transfer_data.transfer_reason or 'Not provided'}" ) # TODO: Send notification emails to both old and new owners # self._send_ownership_transfer_emails(vendor, old_owner, new_owner, transfer_data.transfer_reason) return vendor, old_owner, new_owner except (ValidationException, UserNotFoundException): db.rollback() raise except Exception as e: db.rollback() logger.error(f"Failed to transfer ownership for vendor {vendor_id}: {str(e)}") raise AdminOperationException( operation="transfer_vendor_ownership", reason=f"Ownership transfer failed: {str(e)}" ) # ============================================================================ # MARKETPLACE IMPORT JOBS # ============================================================================ def get_marketplace_import_jobs( self, db: Session, marketplace: Optional[str] = None, vendor_name: Optional[str] = None, status: Optional[str] = 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).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, 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, 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()