from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError from src.models.models import User, Client from src.schemas.user import UserCreate from src.utils.security import get_password_hash, verify_password, generate_token from src.services.email_service import ( send_verification_email, send_password_reset_email, ) from datetime import datetime, timedelta import uuid import logging from typing import Optional, Tuple logger = logging.getLogger(__name__) def create_user( db: Session, user_data: UserCreate, is_admin: bool = False, client_id: Optional[uuid.UUID] = None, auto_verify: bool = False, ) -> Tuple[Optional[User], str]: """ Creates a new user in the system Args: db: Database session user_data: User data to be created is_admin: If the user is an administrator client_id: Associated client ID (optional, a new one will be created if not provided) auto_verify: If True, user is created with email already verified and active Returns: Tuple[Optional[User], str]: Tuple with the created user (or None in case of error) and status message """ try: # Check if email already exists db_user = db.query(User).filter(User.email == user_data.email).first() if db_user: logger.warning( f"Attempt to register with existing email: {user_data.email}" ) return None, "Email already registered" # Create verification token if needed verification_token = None token_expiry = None if not auto_verify: verification_token = generate_token() token_expiry = datetime.utcnow() + timedelta(hours=24) # Start transaction user = None local_client_id = client_id try: # If not admin and no client_id, create an associated client if not is_admin and local_client_id is None: client = Client(name=user_data.name, email=user_data.email) db.add(client) db.flush() # Get the client ID local_client_id = client.id # Create user user = User( email=user_data.email, password_hash=get_password_hash(user_data.password), client_id=local_client_id, is_admin=is_admin, is_active=auto_verify, email_verified=auto_verify, verification_token=verification_token, verification_token_expiry=token_expiry, ) db.add(user) db.commit() # Send verification email if not auto-verified if not auto_verify: email_sent = send_verification_email(user.email, verification_token) if not email_sent: logger.error(f"Failed to send verification email to {user.email}") # We don't do rollback here, we just log the error logger.info(f"User created successfully: {user.email}") return ( user, "User created successfully. Check your email to activate your account.", ) else: logger.info(f"User created and auto-verified: {user.email}") return ( user, "User created successfully.", ) except SQLAlchemyError as e: db.rollback() logger.error(f"Error creating user: {str(e)}") return None, f"Error creating user: {str(e)}" except Exception as e: logger.error(f"Unexpected error creating user: {str(e)}") return None, f"Unexpected error: {str(e)}" def verify_email(db: Session, token: str) -> Tuple[bool, str]: """ Verify the user's email using the provided token Args: db: Database session token: Verification token Returns: Tuple[bool, str]: Tuple with verification status and message """ try: # Search for user by token user = db.query(User).filter(User.verification_token == token).first() if not user: logger.warning(f"Attempt to verify with invalid token: {token}") return False, "Invalid verification token" # Check if the token has expired now = datetime.utcnow() expiry = user.verification_token_expiry # Ensure both dates are of the same type (aware or naive) if expiry.tzinfo is not None and now.tzinfo is None: # If expiry has timezone and now doesn't, convert now to have timezone now = now.replace(tzinfo=expiry.tzinfo) elif now.tzinfo is not None and expiry.tzinfo is None: # If now has timezone and expiry doesn't, convert expiry to have timezone expiry = expiry.replace(tzinfo=now.tzinfo) if expiry < now: logger.warning( f"Attempt to verify with expired token for user: {user.email}" ) return False, "Verification token expired" # Update user user.email_verified = True user.is_active = True user.verification_token = None user.verification_token_expiry = None db.commit() logger.info(f"Email verified successfully for user: {user.email}") return True, "Email verified successfully. Your account is active." except SQLAlchemyError as e: db.rollback() logger.error(f"Error verifying email: {str(e)}") return False, f"Error verifying email: {str(e)}" except Exception as e: logger.error(f"Unexpected error verifying email: {str(e)}") return False, f"Unexpected error: {str(e)}" def resend_verification(db: Session, email: str) -> Tuple[bool, str]: """ Resend the verification email Args: db: Database session email: User email Returns: Tuple[bool, str]: Tuple with operation status and message """ try: # Search for user by email user = db.query(User).filter(User.email == email).first() if not user: logger.warning( f"Attempt to resend verification email for non-existent email: {email}" ) return False, "Email not found" if user.email_verified: logger.info( f"Attempt to resend verification email for already verified email: {email}" ) return False, "Email already verified" # Generate new token verification_token = generate_token() token_expiry = datetime.utcnow() + timedelta(hours=24) # Update user user.verification_token = verification_token user.verification_token_expiry = token_expiry db.commit() # Send email email_sent = send_verification_email(user.email, verification_token) if not email_sent: logger.error(f"Failed to resend verification email to {user.email}") return False, "Failed to send verification email" logger.info(f"Verification email resent successfully to: {user.email}") return True, "Verification email resent. Check your inbox." except SQLAlchemyError as e: db.rollback() logger.error(f"Error resending verification: {str(e)}") return False, f"Error resending verification: {str(e)}" except Exception as e: logger.error(f"Unexpected error resending verification: {str(e)}") return False, f"Unexpected error: {str(e)}" def forgot_password(db: Session, email: str) -> Tuple[bool, str]: """ Initiates the password recovery process Args: db: Database session email: User email Returns: Tuple[bool, str]: Tuple with operation status and message """ try: # Search for user by email user = db.query(User).filter(User.email == email).first() if not user: # For security, we don't inform if the email exists or not logger.info(f"Attempt to recover password for non-existent email: {email}") return ( True, "If the email is registered, you will receive instructions to reset your password.", ) # Generate reset token reset_token = generate_token() token_expiry = datetime.utcnow() + timedelta(hours=1) # Token valid for 1 hour # Update user user.password_reset_token = reset_token user.password_reset_expiry = token_expiry db.commit() # Send email email_sent = send_password_reset_email(user.email, reset_token) if not email_sent: logger.error(f"Failed to send password reset email to {user.email}") return False, "Failed to send password reset email" logger.info(f"Password reset email sent successfully to: {user.email}") return ( True, "If the email is registered, you will receive instructions to reset your password.", ) except SQLAlchemyError as e: db.rollback() logger.error(f"Error processing password recovery: {str(e)}") return False, f"Error processing password recovery: {str(e)}" except Exception as e: logger.error(f"Unexpected error processing password recovery: {str(e)}") return False, f"Unexpected error: {str(e)}" def reset_password(db: Session, token: str, new_password: str) -> Tuple[bool, str]: """ Resets the user's password using the provided token Args: db: Database session token: Password reset token new_password: New password Returns: Tuple[bool, str]: Tuple with operation status and message """ try: # Search for user by token user = db.query(User).filter(User.password_reset_token == token).first() if not user: logger.warning(f"Attempt to reset password with invalid token: {token}") return False, "Invalid password reset token" # Check if the token has expired if user.password_reset_expiry < datetime.utcnow(): logger.warning( f"Attempt to reset password with expired token for user: {user.email}" ) return False, "Password reset token expired" # Update password user.password_hash = get_password_hash(new_password) user.password_reset_token = None user.password_reset_expiry = None db.commit() logger.info(f"Password reset successfully for user: {user.email}") return ( True, "Password reset successfully. You can now login with your new password.", ) except SQLAlchemyError as e: db.rollback() logger.error(f"Error resetting password: {str(e)}") return False, f"Error resetting password: {str(e)}" except Exception as e: logger.error(f"Unexpected error resetting password: {str(e)}") return False, f"Unexpected error: {str(e)}" def get_user_by_email(db: Session, email: str) -> Optional[User]: """ Searches for a user by email Args: db: Database session email: User email Returns: Optional[User]: User found or None """ try: return db.query(User).filter(User.email == email).first() except Exception as e: logger.error(f"Error searching for user by email: {str(e)}") return None def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: """ Authenticates a user with email and password Args: db: Database session email: User email password: User password Returns: Optional[User]: Authenticated user or None """ user = get_user_by_email(db, email) if not user: return None if not verify_password(password, user.password_hash): return None if not user.is_active: return None return user def get_admin_users(db: Session, skip: int = 0, limit: int = 100): """ Lists the admin users Args: db: Database session skip: Number of records to skip limit: Maximum number of records to return Returns: List[User]: List of admin users """ try: users = db.query(User).filter(User.is_admin).offset(skip).limit(limit).all() logger.info(f"List of admins: {len(users)} found") return users except SQLAlchemyError as e: logger.error(f"Error listing admins: {str(e)}") return [] except Exception as e: logger.error(f"Unexpected error listing admins: {str(e)}") return [] def create_admin_user(db: Session, user_data: UserCreate) -> Tuple[Optional[User], str]: """ Creates a new admin user Args: db: Database session user_data: User data to be created Returns: Tuple[Optional[User], str]: Tuple with the created user (or None in case of error) and status message """ return create_user(db, user_data, is_admin=True, auto_verify=True) def deactivate_user(db: Session, user_id: uuid.UUID) -> Tuple[bool, str]: """ Deactivates a user (does not delete, only marks as inactive) Args: db: Database session user_id: ID of the user to be deactivated Returns: Tuple[bool, str]: Tuple with operation status and message """ try: # Search for user by ID user = db.query(User).filter(User.id == user_id).first() if not user: logger.warning(f"Attempt to deactivate non-existent user: {user_id}") return False, "User not found" # Deactivate user user.is_active = False db.commit() logger.info(f"User deactivated successfully: {user.email}") return True, "User deactivated successfully" except SQLAlchemyError as e: db.rollback() logger.error(f"Error deactivating user: {str(e)}") return False, f"Error deactivating user: {str(e)}" except Exception as e: logger.error(f"Unexpected error deactivating user: {str(e)}") return False, f"Unexpected error: {str(e)}" def change_password( db: Session, user_id: uuid.UUID, current_password: str, new_password: str ) -> Tuple[bool, str]: """ Changes the password of an authenticated user Args: db: Database session user_id: ID of the user current_password: Current password for verification new_password: New password to set Returns: Tuple[bool, str]: Tuple with operation status and message """ try: # Search for user by ID user = db.query(User).filter(User.id == user_id).first() if not user: logger.warning( f"Attempt to change password for non-existent user: {user_id}" ) return False, "User not found" # Verify current password if not verify_password(current_password, user.password_hash): logger.warning( f"Attempt to change password with invalid current password for user: {user.email}" ) return False, "Current password is incorrect" # Update password user.password_hash = get_password_hash(new_password) db.commit() logger.info(f"Password changed successfully for user: {user.email}") return True, "Password changed successfully" except SQLAlchemyError as e: db.rollback() logger.error(f"Error changing password: {str(e)}") return False, f"Error changing password: {str(e)}" except Exception as e: logger.error(f"Unexpected error changing password: {str(e)}") return False, f"Unexpected error: {str(e)}"