From 90b6ce092ae15f9a9c42331472fb7900f76207b3 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 22 Jul 2025 16:17:46 +0300 Subject: [PATCH 1/3] refactor(user): Introduce PromoActivationService This commit refactors the promo code activation logic by extracting it from the `PromoActivateView` into a dedicated `PromoActivationService`. Key changes: - Created `user/services.py` to house the new service class. - The `PromoActivationService` now encapsulates all business logic for activating a promo code, including: - User targeting validation (age, country). - Promotion active status check. - Anti-fraud system verification. - Atomic issuance of common or unique promo codes. - Introduced a set of custom, specific exceptions (`PromoActivationError`, `TargetingError`, etc.) to provide clearer error feedback to the API client. - The `PromoActivateView` is now significantly simplified, delegating all logic to the service and handling its success or failure responses. --- promo_code/user/services.py | 155 ++++++++++++++++++++++++++++++++++++ promo_code/user/views.py | 143 +++++---------------------------- 2 files changed, 177 insertions(+), 121 deletions(-) create mode 100644 promo_code/user/services.py diff --git a/promo_code/user/services.py b/promo_code/user/services.py new file mode 100644 index 0000000..43e9fab --- /dev/null +++ b/promo_code/user/services.py @@ -0,0 +1,155 @@ +import django.db.models +import django.db.transaction +import django.utils.timezone +import rest_framework.exceptions + +import business.constants +import business.models +import user.antifraud_service +import user.models + + +class PromoActivationError(rest_framework.exceptions.APIException): + """Base exception for all promo code activation errors.""" + + status_code = 403 + default_detail = 'Failed to activate promo code.' + default_code = 'promo_activation_failed' + + +class TargetingError(PromoActivationError): + """Error for targeting mismatch.""" + + default_detail = 'You do not meet the promotion requirements.' + default_code = 'targeting_mismatch' + + +class PromoInactiveError(PromoActivationError): + """Error if the promo code is inactive.""" + + default_detail = 'This promotion is currently inactive.' + default_code = 'promo_inactive' + + +class PromoUnavailableError(PromoActivationError): + """Error if all promo codes have been used.""" + + default_detail = ( + 'Unfortunately, all codes for this promotion have been used.' + ) + default_code = 'promo_unavailable' + + +class AntiFraudError(PromoActivationError): + """Error from the anti-fraud system.""" + + default_detail = 'Activation is blocked by the security system.' + default_code = 'antifraud_block' + + +class PromoActivationService: + """Service to encapsulate promo code activation logic.""" + + def __init__(self, user: user.models.User, promo: business.models.Promo): + self.user = user + self.promo = promo + + def activate(self) -> str: + """ + Main method that starts the validation and activation process. + Returns the promo code on success. + """ + self._validate_targeting() + self._validate_is_active() + self._validate_antifraud() + + return self._issue_promo_code() + + def _validate_targeting(self): + """Checks if the user matches the promotion's targeting settings.""" + target = self.promo.target + + user_age = self.user.other.get('age') + user_country = ( + self.user.other.get('country', '').lower() + if self.user.other.get('country') + else None + ) + + if target.get('country') and user_country != target['country'].lower(): + raise TargetingError('Country mismatch.') + + if target.get('age_from') and ( + user_age is None or user_age < target['age_from'] + ): + raise TargetingError('Age mismatch.') + + if target.get('age_until') and ( + user_age is None or user_age > target['age_until'] + ): + raise TargetingError('Age mismatch.') + + def _validate_is_active(self): + """Checks if the promo is active and codes are available.""" + + if not self.promo.active or not self.promo.is_active: + raise PromoInactiveError() + + def _validate_antifraud(self): + """Sends a request to the anti-fraud system.""" + antifraud_response = ( + user.antifraud_service.antifraud_service.get_verdict( + self.user.email, + str(self.promo.id), + ) + ) + if not antifraud_response.get('ok'): + raise AntiFraudError() + + def _issue_promo_code(self) -> str: + """ + Issues a promo code in an atomic transaction, updates counters, + and creates a record in the history. + """ + try: + with django.db.transaction.atomic(): + promo_locked = ( + business.models.Promo.objects.select_for_update().get( + id=self.promo.id, + ) + ) + promo_code_value = None + + if promo_locked.mode == business.constants.PROMO_MODE_COMMON: + if promo_locked.used_count < promo_locked.max_count: + promo_locked.used_count = ( + django.db.models.F('used_count') + 1 + ) + promo_locked.save(update_fields=['used_count']) + promo_code_value = promo_locked.promo_common + else: + raise PromoUnavailableError() + + elif promo_locked.mode == business.constants.PROMO_MODE_UNIQUE: + unique_code = promo_locked.unique_codes.filter( + is_used=False, + ).first() + if unique_code: + unique_code.is_used = True + unique_code.used_at = django.utils.timezone.now() + unique_code.save(update_fields=['is_used', 'used_at']) + promo_code_value = unique_code.code + else: + raise PromoUnavailableError() + + if promo_code_value: + user.models.PromoActivationHistory.objects.create( + user=self.user, + promo=promo_locked, + ) + return promo_code_value + + raise PromoActivationError('Invalid promotion type.') + + except business.models.Promo.DoesNotExist: + raise PromoActivationError('Promo not found.') diff --git a/promo_code/user/views.py b/promo_code/user/views.py index ad80c5b..b5ce72b 100644 --- a/promo_code/user/views.py +++ b/promo_code/user/views.py @@ -14,6 +14,7 @@ import user.models import user.permissions import user.serializers +import user.services class UserSignUpView( @@ -296,138 +297,38 @@ def destroy(self, request, *args, **kwargs): class PromoActivateView(rest_framework.views.APIView): - permission_classes = [rest_framework.permissions.IsAuthenticated] - allowed_methods = ['post', 'options', 'head'] - - def _validate_targeting(self, user_, promo): - user_age = user_.other.get('age') - user_country = user_.other.get('country').lower() - target = promo.target - - if not target: - return None - - if target.get('country') and user_country != target['country'].lower(): - return rest_framework.response.Response( - {'error': 'Targeting mismatch: country.'}, - status=rest_framework.status.HTTP_403_FORBIDDEN, - ) - if target.get('age_from') and ( - user_age is None or user_age < target['age_from'] - ): - return rest_framework.response.Response( - {'error': 'Targeting mismatch: age.'}, - status=rest_framework.status.HTTP_403_FORBIDDEN, - ) - if target.get('age_until') and ( - user_age is None or user_age > target['age_until'] - ): - return rest_framework.response.Response( - {'error': 'Targeting mismatch: age.'}, - status=rest_framework.status.HTTP_403_FORBIDDEN, - ) - return None - - def _validate_is_active(self, promo): - if not promo.active or not promo.is_active: - return rest_framework.response.Response( - {'error': 'Promo is not active.'}, - status=rest_framework.status.HTTP_403_FORBIDDEN, - ) - return None - - def _validate_antifraud(self, user_, promo): - antifraud_response = ( - user.antifraud_service.antifraud_service.get_verdict( - user_.email, - str(promo.id), - ) - ) - if not antifraud_response.get('ok'): - return rest_framework.response.Response( - {'error': 'Activation forbidden by anti-fraud system.'}, - status=rest_framework.status.HTTP_403_FORBIDDEN, - ) - return None + """ + Activates a promo code for the user. + All business logic is encapsulated in PromoActivationService. + """ - def _activate_code(self, user_, promo): - try: - with django.db.transaction.atomic(): - promo_for_update = ( - business.models.Promo.objects.select_for_update().get( - id=promo.id, - ) - ) - promo_code_value = None - - if ( - promo_for_update.mode - == business.constants.PROMO_MODE_COMMON - ): - if ( - promo_for_update.used_count - < promo_for_update.max_count - ): - promo_for_update.used_count += 1 - promo_for_update.save(update_fields=['used_count']) - promo_code_value = promo_for_update.promo_common - else: - raise ValueError('No common codes left.') - - elif ( - promo_for_update.mode - == business.constants.PROMO_MODE_UNIQUE - ): - unique_code = promo_for_update.unique_codes.filter( - is_used=False, - ).first() - if unique_code: - unique_code.is_used = True - unique_code.used_at = django.utils.timezone.now() - unique_code.save(update_fields=['is_used', 'used_at']) - promo_code_value = unique_code.code - else: - raise ValueError('No unique codes left.') - - if promo_code_value: - user.models.PromoActivationHistory.objects.create( - user=user_, - promo=promo, - ) - serializer = user.serializers.PromoActivationSerializer( - data={'promo': promo_code_value}, - ) - serializer.is_valid(raise_exception=True) - return rest_framework.response.Response( - serializer.data, - status=rest_framework.status.HTTP_200_OK, - ) - - raise ValueError('Promo code could not be activated.') - - except ValueError as e: - return rest_framework.response.Response( - {'error': str(e)}, - status=rest_framework.status.HTTP_403_FORBIDDEN, - ) + permission_classes = [rest_framework.permissions.IsAuthenticated] def post(self, request, id): promo = django.shortcuts.get_object_or_404( business.models.Promo, id=id, ) - user_ = request.user - if (response := self._validate_targeting(user_, promo)) is not None: - return response + service = user.services.PromoActivationService( + user=request.user, + promo=promo, + ) - if (response := self._validate_is_active(promo)) is not None: - return response + try: + promo_code = service.activate() - if (response := self._validate_antifraud(user_, promo)) is not None: - return response + serializer = user.serializers.PromoActivationSerializer( + data={'promo': promo_code}, + ) + serializer.is_valid(raise_exception=True) + return rest_framework.response.Response(serializer.data) - return self._activate_code(user_, promo) + except user.services.PromoActivationError as e: + return rest_framework.response.Response( + {'error': e.detail}, + status=e.status_code, + ) class PromoHistoryView(rest_framework.generics.ListAPIView): From 1a8e040ebda8686cafa21a1797363cdd1abe7b87 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 23 Jul 2025 14:30:37 +0300 Subject: [PATCH 2/3] refactor(validators): Simplify and refactor password validators This commit refactors the password validation classes to improve clarity, reduce complexity, and align better with Django conventions. The key changes include: - Removed the `BaseCountPasswordValidator` abstract base class, as it added unnecessary complexity. Each validator is now a self-contained class. - Simplified the logic within each validator, using more direct and efficient methods for character checks (e.g., `isascii()`, `isupper()`). - Renamed validator classes for conciseness (e.g., `ASCIIOnlyPasswordValidator` to `AsciiValidator`). - Improved internationalization and pluralization of error messages by using `ngettext`. - Updated `settings.py` to reflect the new validator class names and their simplified options. --- promo_code/promo_code/settings.py | 16 +- promo_code/promo_code/validators.py | 310 ++++++++++++---------------- 2 files changed, 137 insertions(+), 189 deletions(-) diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index 72f329b..658259e 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -178,27 +178,23 @@ def load_bool(name, default): '.NumericPasswordValidator', }, { - 'NAME': 'promo_code.validators.ASCIIOnlyPasswordValidator', + 'NAME': 'promo_code.validators.AsciiValidator', }, { - 'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator', - 'OPTIONS': {'min_count': 1}, + 'NAME': 'promo_code.validators.SpecialCharacterValidator', + 'OPTIONS': {'special_chars': '[@$!%*?&]'}, }, { - 'NAME': 'promo_code.validators.NumericPasswordValidator', - 'OPTIONS': {'min_count': 1}, + 'NAME': 'promo_code.validators.NumericValidator', }, { - 'NAME': 'promo_code.validators.LowercaseLatinLetterPasswordValidator', - 'OPTIONS': {'min_count': 1}, + 'NAME': 'promo_code.validators.LowercaseValidator', }, { - 'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator', - 'OPTIONS': {'min_count': 1}, + 'NAME': 'promo_code.validators.UppercaseValidator', }, ] - PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', diff --git a/promo_code/promo_code/validators.py b/promo_code/promo_code/validators.py index 57a5a74..d80e915 100644 --- a/promo_code/promo_code/validators.py +++ b/promo_code/promo_code/validators.py @@ -1,237 +1,189 @@ -import abc import re import django.core.exceptions +import django.utils.translation from django.utils.translation import gettext as _ -class BaseCountPasswordValidator(abc.ABC): +class UppercaseValidator: """ - Abstract base class for password validators checking - character count requirements. - - Attributes: - min_count (int): Minimum required character count (>=1) - - Raises: - ValueError: If min_count is less than 1 during initialization + Validates that the password contains at least a minimum number of uppercase + ASCII letters. """ def __init__(self, min_count=1): - if min_count < 1: - raise ValueError('min_count must be at least 1') - self.min_count = min_count - - @abc.abstractmethod - def get_help_text(self) -> str: - """Abstract method to return user-friendly help text""" - pass + self.code = 'password_no_uppercase' def validate(self, password, user=None): - """ - Validate password meets the character count requirement - - Args: - password (str): Password to validate - user (User): Optional user object (not used) - - Raises: - ValidationError: If validation fails - """ - count = sum(1 for char in password if self.validate_char(char)) + count = sum( + 1 for char in password if char.isupper() and char.isascii() + ) if count < self.min_count: + msg = django.utils.translation.ngettext( + 'Password must contain at least %(min_count)d uppercase ' + 'letter.', + 'Password must contain at least %(min_count)d uppercase ' + 'letters.', + self.min_count, + ) % { + 'min_count': self.min_count, + } raise django.core.exceptions.ValidationError( - self.get_error_message(), - code=self.get_code(), + msg, + code=self.code, + params={'min_count': self.min_count}, ) - def validate_char(self, char) -> bool: - """ - Check if character meets validation criteria - - Args: - char (str): Single character to check - - Returns: - bool: Validation result - """ - raise NotImplementedError - - def get_code(self) -> str: - """Get error code identifier""" - return getattr(self, 'code', 'base_code') - - def get_error_message(self) -> str: - """Get localized error message""" - raise NotImplementedError - - -class SpecialCharacterPasswordValidator(BaseCountPasswordValidator): - """ - Validates presence of minimum required special characters - - Args: - special_chars (str): Regex pattern for valid special characters - min_count (int): Minimum required count (default: 1) - - Example: - SpecialCharacterValidator(r'[!@#$%^&*]', min_count=2) - """ - - def __init__( - self, - special_chars=r'[!@#$%^&*()_+\-=\[\]{};\':",./<>?`~\\]', - min_count=1, - ): - super().__init__(min_count) - self.pattern = re.compile(special_chars) - self.code = 'password_no_special_char' - - def validate_char(self, char) -> bool: - """Check if character matches special characters pattern""" - return bool(self.pattern.match(char)) - - def get_help_text(self) -> str: - return _( - ( - f'Your password must contain at least {self.min_count} ' - 'special character(s).' - ), - ) - - def get_error_message(self) -> str: + def get_help_text(self): return _( - ( - f'Password must contain at least {self.min_count} ' - 'special character(s).' - ), - ) + 'Your password must contain at least %(min_count)d uppercase ' + 'letter.' if self.min_count == 1 else + 'Your password must contain at least %(min_count)d uppercase ' + 'letters.', + ) % { + 'min_count': self.min_count, + } -class NumericPasswordValidator(BaseCountPasswordValidator): +class LowercaseValidator: """ - Validates presence of minimum required numeric digits - - Args: - min_count (int): Minimum required digits (default: 1) + Validates that the password contains at least a minimum number of lowercase + ASCII letters. """ def __init__(self, min_count=1): - super().__init__(min_count) - self.code = 'password_no_number' - - def validate_char(self, char) -> bool: - """Check if character is a digit""" - return char.isdigit() + self.min_count = min_count + self.code = 'password_no_lowercase' - def get_help_text(self) -> str: - return _( - f'Your password must contain at least {self.min_count} digit(s).', + def validate(self, password, user=None): + count = sum( + 1 for char in password if char.islower() and char.isascii() ) + if count < self.min_count: + msg = django.utils.translation.ngettext( + 'Password must contain at least %(min_count)d lowercase ' + 'letter.', + 'Password must contain at least %(min_count)d lowercase ' + 'letters.', + self.min_count, + ) % { + 'min_count': self.min_count, + } + raise django.core.exceptions.ValidationError( + msg, + code=self.code, + params={'min_count': self.min_count}, + ) - def get_error_message(self) -> str: - return _(f'Password must contain at least {self.min_count} digit(s).') + def get_help_text(self): + return _( + 'Your password must contain at least %(min_count)d lowercase ' + 'letter.' if self.min_count == 1 else + 'Your password must contain at least %(min_count)d lowercase ' + 'letters.', + ) % { + 'min_count': self.min_count, + } -class LowercaseLatinLetterPasswordValidator(BaseCountPasswordValidator): +class NumericValidator: """ - Validates presence of minimum required lowercase Latin letters - - Args: - min_count (int): Minimum required lowercase letters (default: 1) + Validates that the password contains at least a minimum number of digits. """ def __init__(self, min_count=1): - super().__init__(min_count) - self.code = 'password_no_lowercase_latin' - - def validate_char(self, char) -> bool: - """Check if character is lower Latin letter""" - return char.islower() and char.isascii() + self.min_count = min_count + self.code = 'password_no_number' - def get_help_text(self) -> str: - return _( - ( - f'Your password must contain at least {self.min_count} ' - 'lowercase Latin letter(s).' - ), - ) + def validate(self, password, user=None): + count = sum(1 for char in password if char.isdigit()) + if count < self.min_count: + msg = django.utils.translation.ngettext( + 'Password must contain at least %(min_count)d digit.', + 'Password must contain at least %(min_count)d digits.', + self.min_count, + ) % { + 'min_count': self.min_count, + } + raise django.core.exceptions.ValidationError( + msg, + code=self.code, + params={'min_count': self.min_count}, + ) - def get_error_message(self) -> str: + def get_help_text(self): return _( - ( - f'Password must contain at least {self.min_count} ' - 'lowercase Latin letter(s).' - ), - ) + 'Your password must contain at least %(min_count)d digit.' + if self.min_count == 1 else + 'Your password must contain at least %(min_count)d digits.', + ) % { + 'min_count': self.min_count, + } -class UppercaseLatinLetterPasswordValidator(BaseCountPasswordValidator): +class SpecialCharacterValidator: """ - Validates presence of minimum required uppercase Latin letters - - Args: - min_count (int): Minimum required uppercase letters (default: 1) + Validates that the password contains at least a minimum number of special + characters. """ - def __init__(self, min_count=1): - super().__init__(min_count) - self.code = 'password_no_uppercase_latin' + DEFAULT_SPECIAL_CHARS = ( + r'[!@#$%^&*()_+\-\=\[\]{};\':",./<>?`~\\]' + ) - def validate_char(self, char) -> bool: - """Check if character is uppercase Latin letter""" - return char.isupper() and char.isascii() + def __init__(self, min_count=1, special_chars=None): + self.min_count = min_count + self.pattern = re.compile(special_chars or self.DEFAULT_SPECIAL_CHARS) + self.code = 'password_no_special_char' - def get_help_text(self) -> str: - return _( - ( - f'Your password must contain at least {self.min_count} ' - 'uppercase Latin letter(s).' - ), - ) + def validate(self, password, user=None): + count = len(self.pattern.findall(password)) + if count < self.min_count: + msg = django.utils.translation.ngettext( + 'Password must contain at least %(min_count)d special ' + 'character.', + 'Password must contain at least %(min_count)d special ' + 'characters.', + self.min_count, + ) % { + 'min_count': self.min_count, + } + raise django.core.exceptions.ValidationError( + msg, + code=self.code, + params={'min_count': self.min_count}, + ) - def get_error_message(self) -> str: + def get_help_text(self): return _( - ( - f'Password must contain at least {self.min_count} ' - 'uppercase Latin letter(s).' - ), - ) + 'Your password must contain at least %(min_count)d special ' + 'character.' if self.min_count == 1 else + 'Your password must contain at least %(min_count)d special ' + 'characters.', + ) % { + 'min_count': self.min_count, + } -class ASCIIOnlyPasswordValidator: +class AsciiValidator: """ - Validates that password contains only ASCII characters - - Example: - - Valid: 'Passw0rd!123' - - Invalid: 'Pässwörd§123' + Validates that the password contains only ASCII characters. """ - code = 'password_not_only_ascii_characters' + def __init__(self): + self.code = 'password_not_ascii' - def validate(self, password, user=None) -> bool: - try: - password.encode('ascii', errors='strict') - except UnicodeEncodeError: + def validate(self, password, user=None): + if not password.isascii(): raise django.core.exceptions.ValidationError( - _('Password contains non-ASCII characters'), + _('Password contains non-ASCII characters.'), code=self.code, ) - def get_help_text(self) -> str: + def get_help_text(self): return _( - ( - 'Your password must contain only standard English letters, ' - 'digits and punctuation symbols (ASCII character set)' - ), + 'Your password must only contain standard English letters, ' + 'digits, and symbols.', ) - def get_error_message(self) -> str: - return _( - ( - 'Your password must contain only standard English letters, ' - 'digits and punctuation symbols (ASCII character set)' - ), - ) From 2b936fa9bd38116e55be698f496052f87175c63c Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 23 Jul 2025 21:18:17 +0300 Subject: [PATCH 3/3] style: Format validators.py to comply with ruff --- promo_code/promo_code/validators.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/promo_code/promo_code/validators.py b/promo_code/promo_code/validators.py index d80e915..26e5f33 100644 --- a/promo_code/promo_code/validators.py +++ b/promo_code/promo_code/validators.py @@ -38,8 +38,9 @@ def validate(self, password, user=None): def get_help_text(self): return _( 'Your password must contain at least %(min_count)d uppercase ' - 'letter.' if self.min_count == 1 else - 'Your password must contain at least %(min_count)d uppercase ' + 'letter.' + if self.min_count == 1 + else 'Your password must contain at least %(min_count)d uppercase ' 'letters.', ) % { 'min_count': self.min_count, @@ -79,8 +80,9 @@ def validate(self, password, user=None): def get_help_text(self): return _( 'Your password must contain at least %(min_count)d lowercase ' - 'letter.' if self.min_count == 1 else - 'Your password must contain at least %(min_count)d lowercase ' + 'letter.' + if self.min_count == 1 + else 'Your password must contain at least %(min_count)d lowercase ' 'letters.', ) % { 'min_count': self.min_count, @@ -115,8 +117,8 @@ def validate(self, password, user=None): def get_help_text(self): return _( 'Your password must contain at least %(min_count)d digit.' - if self.min_count == 1 else - 'Your password must contain at least %(min_count)d digits.', + if self.min_count == 1 + else 'Your password must contain at least %(min_count)d digits.', ) % { 'min_count': self.min_count, } @@ -128,9 +130,7 @@ class SpecialCharacterValidator: characters. """ - DEFAULT_SPECIAL_CHARS = ( - r'[!@#$%^&*()_+\-\=\[\]{};\':",./<>?`~\\]' - ) + DEFAULT_SPECIAL_CHARS = r'[!@#$%^&*()_+\-\=\[\]{};\':",./<>?`~\\]' def __init__(self, min_count=1, special_chars=None): self.min_count = min_count @@ -158,8 +158,9 @@ def validate(self, password, user=None): def get_help_text(self): return _( 'Your password must contain at least %(min_count)d special ' - 'character.' if self.min_count == 1 else - 'Your password must contain at least %(min_count)d special ' + 'character.' + if self.min_count == 1 + else 'Your password must contain at least %(min_count)d special ' 'characters.', ) % { 'min_count': self.min_count, @@ -186,4 +187,3 @@ def get_help_text(self): 'Your password must only contain standard English letters, ' 'digits, and symbols.', ) -