# app/services/vendor_team_service.py """ Vendor team management service. Handles: - Team member invitations - Invitation acceptance - Role assignment - Permission management """ import logging import secrets from datetime import datetime, timedelta from typing import Any from sqlalchemy.orm import Session from app.core.permissions import get_preset_permissions from app.exceptions import ( CannotRemoveOwnerException, InvalidInvitationTokenException, MaxTeamMembersReachedException, TeamInvitationAlreadyAcceptedException, TeamMemberAlreadyExistsException, UserNotFoundException, ) from middleware.auth import AuthManager from models.database.user import User from models.database.vendor import Role, Vendor, VendorUser, VendorUserType logger = logging.getLogger(__name__) class VendorTeamService: """Service for managing vendor team members.""" def __init__(self): self.auth_manager = AuthManager() self.max_team_members = 50 # Configure as needed def invite_team_member( self, db: Session, vendor: Vendor, inviter: User, email: str, role_name: str, custom_permissions: list[str] | None = None, ) -> dict[str, Any]: """ Invite a new team member to a vendor. Creates: 1. User account (if doesn't exist) 2. Role (if custom permissions provided) 3. VendorUser relationship with invitation token Args: db: Database session vendor: Vendor to invite to inviter: User sending the invitation email: Email of person to invite role_name: Role name (manager, staff, support, etc.) custom_permissions: Optional custom permissions (overrides preset) Returns: Dict with invitation details """ try: # Check team size limit current_team_size = ( db.query(VendorUser) .filter( VendorUser.vendor_id == vendor.id, VendorUser.is_active == True, ) .count() ) if current_team_size >= self.max_team_members: raise MaxTeamMembersReachedException( self.max_team_members, vendor.vendor_code, ) # Check if user already exists user = db.query(User).filter(User.email == email).first() if user: # Check if already a member existing_membership = ( db.query(VendorUser) .filter( VendorUser.vendor_id == vendor.id, VendorUser.user_id == user.id, ) .first() ) if existing_membership: if existing_membership.is_active: raise TeamMemberAlreadyExistsException( email, vendor.vendor_code ) # Reactivate old membership existing_membership.is_active = ( False # Will be activated on acceptance ) existing_membership.invitation_token = ( self._generate_invitation_token() ) existing_membership.invitation_sent_at = datetime.utcnow() existing_membership.invitation_accepted_at = None db.flush() logger.info( f"Re-invited user {email} to vendor {vendor.vendor_code}" ) return { "invitation_token": existing_membership.invitation_token, "email": email, "existing_user": True, } else: # Create new user account (inactive until invitation accepted) username = email.split("@")[0] # Ensure unique username base_username = username counter = 1 while db.query(User).filter(User.username == username).first(): username = f"{base_username}{counter}" counter += 1 # Generate temporary password (user will set real one on activation) temp_password = secrets.token_urlsafe(16) user = User( email=email, username=username, hashed_password=self.auth_manager.hash_password(temp_password), role="vendor", # Platform role is_active=False, # Will be activated when invitation accepted is_email_verified=False, ) db.add(user) db.flush() # Get user.id logger.info(f"Created new user account for invitation: {email}") # Get or create role role = self._get_or_create_role( db=db, vendor=vendor, role_name=role_name, custom_permissions=custom_permissions, ) # Create vendor membership with invitation invitation_token = self._generate_invitation_token() vendor_user = VendorUser( vendor_id=vendor.id, user_id=user.id, user_type=VendorUserType.TEAM_MEMBER.value, role_id=role.id, invited_by=inviter.id, invitation_token=invitation_token, invitation_sent_at=datetime.utcnow(), is_active=False, # Will be activated on acceptance ) db.add(vendor_user) db.flush() logger.info( f"Invited {email} to vendor {vendor.vendor_code} " f"as {role_name} by {inviter.username}" ) # TODO: Send invitation email # self._send_invitation_email(email, vendor, invitation_token) return { "invitation_token": invitation_token, "email": email, "role": role_name, "existing_user": user.is_active, } except (TeamMemberAlreadyExistsException, MaxTeamMembersReachedException): raise except Exception as e: logger.error(f"Error inviting team member: {str(e)}") raise def accept_invitation( self, db: Session, invitation_token: str, password: str, first_name: str | None = None, last_name: str | None = None, ) -> dict[str, Any]: """ Accept a team invitation and activate account. Args: db: Database session invitation_token: Invitation token from email password: New password to set first_name: Optional first name last_name: Optional last name Returns: Dict with user and vendor info """ try: # Find invitation vendor_user = ( db.query(VendorUser) .filter( VendorUser.invitation_token == invitation_token, ) .first() ) if not vendor_user: raise InvalidInvitationTokenException() # Check if already accepted if vendor_user.invitation_accepted_at is not None: raise TeamInvitationAlreadyAcceptedException() # Check token expiration (7 days) if vendor_user.invitation_sent_at: expiry_date = vendor_user.invitation_sent_at + timedelta(days=7) if datetime.utcnow() > expiry_date: raise InvalidInvitationTokenException("Invitation has expired") user = vendor_user.user vendor = vendor_user.vendor # Update user user.hashed_password = self.auth_manager.hash_password(password) user.is_active = True user.is_email_verified = True if first_name: user.first_name = first_name if last_name: user.last_name = last_name # Activate membership vendor_user.is_active = True vendor_user.invitation_accepted_at = datetime.utcnow() vendor_user.invitation_token = None # Clear token db.flush() logger.info( f"User {user.email} accepted invitation to vendor {vendor.vendor_code}" ) return { "user": user, "vendor": vendor, "role": vendor_user.role.name if vendor_user.role else "member", } except ( InvalidInvitationTokenException, TeamInvitationAlreadyAcceptedException, ): raise except Exception as e: logger.error(f"Error accepting invitation: {str(e)}") raise def remove_team_member( self, db: Session, vendor: Vendor, user_id: int, ) -> bool: """ Remove a team member from a vendor. Cannot remove owner. Args: db: Database session vendor: Vendor to remove from user_id: User ID to remove Returns: True if removed """ try: vendor_user = ( db.query(VendorUser) .filter( VendorUser.vendor_id == vendor.id, VendorUser.user_id == user_id, ) .first() ) if not vendor_user: raise UserNotFoundException(str(user_id)) # Cannot remove owner if vendor_user.is_owner: raise CannotRemoveOwnerException(vendor.vendor_code) # Soft delete - just deactivate vendor_user.is_active = False logger.info(f"Removed user {user_id} from vendor {vendor.vendor_code}") return True except (UserNotFoundException, CannotRemoveOwnerException): raise except Exception as e: logger.error(f"Error removing team member: {str(e)}") raise def update_member_role( self, db: Session, vendor: Vendor, user_id: int, new_role_name: str, custom_permissions: list[str] | None = None, ) -> VendorUser: """ Update a team member's role. Args: db: Database session vendor: Vendor user_id: User ID new_role_name: New role name custom_permissions: Optional custom permissions Returns: Updated VendorUser """ try: vendor_user = ( db.query(VendorUser) .filter( VendorUser.vendor_id == vendor.id, VendorUser.user_id == user_id, ) .first() ) if not vendor_user: raise UserNotFoundException(str(user_id)) # Cannot change owner's role if vendor_user.is_owner: raise CannotRemoveOwnerException(vendor.vendor_code) # Get or create new role new_role = self._get_or_create_role( db=db, vendor=vendor, role_name=new_role_name, custom_permissions=custom_permissions, ) vendor_user.role_id = new_role.id db.flush() logger.info( f"Updated role for user {user_id} in vendor {vendor.vendor_code} " f"to {new_role_name}" ) return vendor_user except (UserNotFoundException, CannotRemoveOwnerException): raise except Exception as e: logger.error(f"Error updating member role: {str(e)}") raise def get_team_members( self, db: Session, vendor: Vendor, include_inactive: bool = False, ) -> list[dict[str, Any]]: """ Get all team members for a vendor. Args: db: Database session vendor: Vendor include_inactive: Include inactive members Returns: List of team member info """ query = db.query(VendorUser).filter( VendorUser.vendor_id == vendor.id, ) if not include_inactive: query = query.filter(VendorUser.is_active == True) vendor_users = query.all() members = [] for vu in vendor_users: members.append( { "id": vu.user.id, "email": vu.user.email, "username": vu.user.username, "full_name": vu.user.full_name, "user_type": vu.user_type, "role": vu.role.name if vu.role else "owner", "permissions": vu.get_all_permissions(), "is_active": vu.is_active, "is_owner": vu.is_owner, "invitation_pending": vu.is_invitation_pending, "invited_at": vu.invitation_sent_at, "accepted_at": vu.invitation_accepted_at, } ) return members # Private helper methods def _generate_invitation_token(self) -> str: """Generate a secure invitation token.""" return secrets.token_urlsafe(32) def _get_or_create_role( self, db: Session, vendor: Vendor, role_name: str, custom_permissions: list[str] | None = None, ) -> Role: """Get existing role or create new one with preset/custom permissions.""" # Try to find existing role with same name role = ( db.query(Role) .filter( Role.vendor_id == vendor.id, Role.name == role_name, ) .first() ) if role and custom_permissions is None: # Use existing role return role # Determine permissions if custom_permissions: permissions = custom_permissions else: # Get preset permissions permissions = list(get_preset_permissions(role_name)) if role: # Update existing role with new permissions role.permissions = permissions else: # Create new role role = Role( vendor_id=vendor.id, name=role_name, permissions=permissions, ) db.add(role) db.flush() return role def _send_invitation_email(self, email: str, vendor: Vendor, token: str): """Send invitation email (TODO: implement).""" # TODO: Implement email sending # Should include: # - Link to accept invitation: /vendor/invitation/accept?token={token} # - Vendor name # - Inviter name # - Expiry date # Create service instance vendor_team_service = VendorTeamService()