From 395164f63064d7097edb882b823f23f3ae54cacb Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Mon, 3 Aug 2026 00:01:15 +0900 Subject: [PATCH] Move users service into a module --- src/services/users.py | 98 ---------------------------------- src/services/users/__init__.py | 5 ++ src/services/users/data.py | 34 ++++++++++++ src/services/users/repo.py | 26 +++++++++ src/services/users/service.py | 50 +++++++++++++++++ 5 files changed, 115 insertions(+), 98 deletions(-) delete mode 100644 src/services/users.py create mode 100644 src/services/users/__init__.py create mode 100644 src/services/users/data.py create mode 100644 src/services/users/repo.py create mode 100644 src/services/users/service.py diff --git a/src/services/users.py b/src/services/users.py deleted file mode 100644 index 3132f33..0000000 --- a/src/services/users.py +++ /dev/null @@ -1,98 +0,0 @@ -from email.headerregistry import Address -import abc -from dataclasses import dataclass -from src.utils.secret import SecretBox -from src.config.email import EmailAddress - - -@dataclass -class UserProfile: - id: int - email: EmailAddress - - -@dataclass -class User: - id: int - email: EmailAddress - email_confirmed: bool - password_hash: SecretBox[str] - - def to_profile(self) -> UserProfile: - return UserProfile(id=self.id, email=self.email) - - -@dataclass -class UserDTO: - email: EmailAddress - raw_password: SecretBox[str] - - -class LoginError(Exception): - pass - - -class SignupError(Exception): - pass - - -class UserRepo(abc.ABC): - @abc.abstractmethod - def create_user(self, user: UserDTO) -> User: - pass - - @abc.abstractmethod - def get_user_by_id(self, user_id: int) -> User | None: - pass - - @abc.abstractmethod - def get_user_by_email(self, email: EmailAddress) -> User | None: - pass - - @abc.abstractmethod - def update_user(self, user: User): - pass - - @abc.abstractmethod - def auth_as_user(self, user: UserDTO) -> User | None: - pass - - -def is_valid_email(email: str) -> bool: - try: - parsed = Address(addr_spec=email) - return bool(parsed.username and parsed.domain and '.' in parsed.domain) - except (ValueError, TypeError): - return False - - -def is_valid_password(password: str) -> bool: - # TODO: Enforce other or saner rules? - long_enough = len(password) > 8 - short_enough = len(password) < 32 - has_symbol = any([s in password for s in list('@#$%^&*!?/')]) - return long_enough and short_enough and has_symbol - - -class UserService: - def __init__(self, repo: UserRepo): - self._repo = repo - - def login(self, user: UserDTO) -> UserProfile | None: - full_user = self._repo.auth_as_user(user) - if full_user: - return full_user.to_profile() - - def signup(self, user: UserDTO) -> UserProfile: - if not is_valid_email(user.email): - raise SignupError(f'{user.email} was not an acceptable email address') - - if not is_valid_password(user.raw_password.expose_secret()): - raise SignupError('The given password was not acceptable') - - return self._repo.create_user(user).to_profile() - - def get_user_by_id(self, user_id: int) -> UserProfile | None: - user = self._repo.get_user_by_id(user_id) - if user: - return user.to_profile() diff --git a/src/services/users/__init__.py b/src/services/users/__init__.py new file mode 100644 index 0000000..f733467 --- /dev/null +++ b/src/services/users/__init__.py @@ -0,0 +1,5 @@ +from .data import User, UserProfile, UserDTO, SignupError, LoginError +from .service import UserService +from .repo import UserRepo + +__all__ = ['User', 'UserProfile', 'UserDTO', 'SignupError', 'LoginError', 'UserService', 'UserRepo'] diff --git a/src/services/users/data.py b/src/services/users/data.py new file mode 100644 index 0000000..cd96b07 --- /dev/null +++ b/src/services/users/data.py @@ -0,0 +1,34 @@ +from src.config.email import EmailAddress +from dataclasses import dataclass +from src.utils.secret import SecretBox + + +@dataclass +class UserProfile: + id: int + email: EmailAddress + + +@dataclass +class User: + id: int + email: EmailAddress + email_confirmed: bool + password_hash: SecretBox[str] + + def to_profile(self) -> UserProfile: + return UserProfile(id=self.id, email=self.email) + + +@dataclass +class UserDTO: + email: EmailAddress + raw_password: SecretBox[str] + + +class LoginError(Exception): + pass + + +class SignupError(Exception): + pass diff --git a/src/services/users/repo.py b/src/services/users/repo.py new file mode 100644 index 0000000..f50836b --- /dev/null +++ b/src/services/users/repo.py @@ -0,0 +1,26 @@ +import abc +from src.config.email import EmailAddress + +from .data import User, UserDTO + + +class UserRepo(abc.ABC): + @abc.abstractmethod + def create_user(self, user: UserDTO) -> User: + pass + + @abc.abstractmethod + def get_user_by_id(self, user_id: int) -> User | None: + pass + + @abc.abstractmethod + def get_user_by_email(self, email: EmailAddress) -> User | None: + pass + + @abc.abstractmethod + def update_user(self, user: User): + pass + + @abc.abstractmethod + def auth_as_user(self, user: UserDTO) -> User | None: + pass diff --git a/src/services/users/service.py b/src/services/users/service.py new file mode 100644 index 0000000..f843fbe --- /dev/null +++ b/src/services/users/service.py @@ -0,0 +1,50 @@ +from src.services.email import EmailService +from email.headerregistry import Address +from .data import UserDTO, UserProfile, SignupError, LoginError +from .repo import UserRepo + + +def is_valid_email(email: str) -> bool: + try: + parsed = Address(addr_spec=email) + return bool(parsed.username and parsed.domain and '.' in parsed.domain) + except (ValueError, TypeError): + return False + + +def is_valid_password(password: str) -> bool: + # TODO: Enforce other or saner rules? + long_enough = len(password) > 8 + short_enough = len(password) < 32 + has_symbol = any([s in password for s in list('@#$%^&*!?/')]) + return long_enough and short_enough and has_symbol + + +class UserService: + def __init__(self, repo: UserRepo, email_service: EmailService): + self._repo = repo + self._email_service = email_service + + def login(self, user: UserDTO) -> UserProfile: + full_user = self._repo.auth_as_user(user) + if full_user: + return full_user.to_profile() + else: + raise LoginError('No user found for that email or password') + + def signup(self, user: UserDTO) -> UserProfile: + if not is_valid_email(user.email): + raise SignupError(f'{user.email} was not an acceptable email address') + + if not is_valid_password(user.raw_password.expose_secret()): + raise SignupError('The given password was not acceptable') + + # Create a user in persistence + created_user = self._repo.create_user(user).to_profile() + + return created_user + + def get_user_by_id(self, user_id: int) -> UserProfile | None: + user = self._repo.get_user_by_id(user_id) + if user: + return user.to_profile()