diff --git a/.flake8 b/.flake8 index b2d6c81..370d558 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 79 -application_import_names = promo_code +application_import_names = promo_code, user import-order-style = google exclude = */migrations/, venv/, verdict.py, .venv/, env/, venv, .git, __pycache__ max-complexity = 10 diff --git a/.github/workflows/lint.yml b/.github/workflows/linting.yml similarity index 91% rename from .github/workflows/lint.yml rename to .github/workflows/linting.yml index 1f28d3d..d5221cb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/linting.yml @@ -1,9 +1,10 @@ name: Linting on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] + workflow_dispatch: jobs: lint: diff --git a/.isort.cfg b/.isort.cfg index 8d3cdbe..4d10ca4 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,7 +1,7 @@ [settings] profile = black skip = migrations, venv/, venv -known_first_party = promo_code +known_first_party = promo_code, user default_section = THIRDPARTY force_sort_within_sections = true line_length = 79 \ No newline at end of file diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index ef31e28..aaaad76 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -1,3 +1,4 @@ +import datetime import os import pathlib @@ -26,19 +27,70 @@ def load_bool(name, default): DEBUG = load_bool('DJANGO_DEBUG', False) -ALLOWED_HOSTS = [] - +ALLOWED_HOSTS = ['*'] INSTALLED_APPS = [ - 'core.apps.CoreConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + # + 'rest_framework', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', + # + 'core.apps.CoreConfig', + 'user.apps.UserConfig', ] +AUTH_USER_MODEL = 'user.User' + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'user.authentication.CustomJWTAuthentication', + ], +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': datetime.timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': False, # ! + # + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUDIENCE': None, + 'ISSUER': None, + 'JSON_ENCODER': None, + 'JWK_URL': None, + 'LEEWAY': 0, + # + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'USER_AUTHENTICATION_RULE': ( + 'rest_framework_simplejwt.authentication' + '.default_user_authentication_rule', + ), + # + 'TOKEN_TYPE_CLAIM': 'token_type', + 'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser', + # + 'JTI_CLAIM': 'jti', + # + 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp', + 'SLIDING_TOKEN_LIFETIME': datetime.timedelta(minutes=5), + 'SLIDING_TOKEN_REFRESH_LIFETIME': datetime.timedelta(days=1), + # + 'ACCESS_TOKEN_CLASS': 'user.tokens.CustomAccessToken', +} + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -83,20 +135,32 @@ def load_bool(name, default): AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.' - 'UserAttributeSimilarityValidator', + 'NAME': 'django.contrib.auth.password_validation' + '.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation' + '.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation' + '.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation' + '.NumericPasswordValidator', + }, + { + 'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.' - 'MinimumLengthValidator', + 'NAME': 'promo_code.validators.NumericPasswordValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.' - 'CommonPasswordValidator', + 'NAME': 'promo_code.validators.LatinLetterPasswordValidator', }, { - 'NAME': 'django.contrib.auth.password_validation.' - 'NumericPasswordValidator', + 'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator', }, ] diff --git a/promo_code/promo_code/urls.py b/promo_code/promo_code/urls.py index 738ad7b..8628dbf 100644 --- a/promo_code/promo_code/urls.py +++ b/promo_code/promo_code/urls.py @@ -3,5 +3,6 @@ urlpatterns = [ django.urls.path('api/ping/', django.urls.include('core.urls')), + django.urls.path('api/user/', django.urls.include('user.urls')), django.urls.path('admin/', django.contrib.admin.site.urls), ] diff --git a/promo_code/promo_code/validators.py b/promo_code/promo_code/validators.py new file mode 100644 index 0000000..664c3fb --- /dev/null +++ b/promo_code/promo_code/validators.py @@ -0,0 +1,201 @@ +import abc +import re +import unicodedata + +import django.core.exceptions +from django.utils.translation import gettext as _ + + +class BaseCountPasswordValidator(abc.ABC): + """ + 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 + """ + + 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 + + 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)) + if count < self.min_count: + raise django.core.exceptions.ValidationError( + self.get_error_message(), + code=self.get_code(), + ) + + 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: + return _( + ( + f'Password must contain at least {self.min_count} ' + 'special character(s).' + ), + ) + + +class NumericPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required numeric digits + + Args: + min_count (int): Minimum required digits (default: 1) + """ + + 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() + + def get_help_text(self) -> str: + return _( + f'Your password must contain at least {self.min_count} digit(s).', + ) + + def get_error_message(self) -> str: + return _(f'Password must contain at least {self.min_count} digit(s).') + + +class LatinLetterPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required Latin letters (ASCII) + + Args: + min_count (int): Minimum required letters (default: 1) + """ + + def __init__(self, min_count=1): + super().__init__(min_count) + self.code = 'password_no_latin_letter' + + def validate_char(self, char) -> bool: + """Check if character is a Latin ASCII letter""" + return unicodedata.category(char).startswith('L') and char.isascii() + + def get_help_text(self) -> str: + return _( + ( + f'Your password must contain at least {self.min_count} ' + 'Latin letter(s).' + ), + ) + + def get_error_message(self) -> str: + return _( + ( + f'Password must contain at least {self.min_count} ' + 'Latin letter(s).' + ), + ) + + +class UppercaseLatinLetterPasswordValidator(BaseCountPasswordValidator): + """ + Validates presence of minimum required uppercase Latin letters + + Args: + min_count (int): Minimum required uppercase letters (default: 1) + """ + + def __init__(self, min_count=1): + super().__init__(min_count) + self.code = 'password_no_uppercase_latin' + + def validate_char(self, char) -> bool: + """Check if character is uppercase Latin letter""" + return char.isupper() and char.isascii() + + def get_help_text(self) -> str: + return _( + ( + f'Your password must contain at least {self.min_count} ' + 'uppercase Latin letter(s).' + ), + ) + + def get_error_message(self) -> str: + return _( + ( + f'Password must contain at least {self.min_count} ' + 'uppercase Latin letter(s).' + ), + ) diff --git a/promo_code/user/__init__.py b/promo_code/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/apps.py b/promo_code/user/apps.py new file mode 100644 index 0000000..5c0320b --- /dev/null +++ b/promo_code/user/apps.py @@ -0,0 +1,6 @@ +import django.apps + + +class UserConfig(django.apps.AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/promo_code/user/authentication.py b/promo_code/user/authentication.py new file mode 100644 index 0000000..5294ea6 --- /dev/null +++ b/promo_code/user/authentication.py @@ -0,0 +1,20 @@ +import datetime + +import rest_framework.exceptions +import rest_framework_simplejwt.authentication + + +class CustomJWTAuthentication( + rest_framework_simplejwt.authentication.JWTAuthentication, +): + def get_user(self, validated_token): + user = super().get_user(validated_token) + last_login_str = validated_token.get('last_login') + if last_login_str: + last_login = datetime.datetime.fromisoformat(last_login_str) + if user.last_login and user.last_login > last_login: + raise rest_framework.exceptions.AuthenticationFailed( + 'Token has been invalidated', + ) + + return user diff --git a/promo_code/user/migrations/0001_initial.py b/promo_code/user/migrations/0001_initial.py new file mode 100644 index 0000000..92150f1 --- /dev/null +++ b/promo_code/user/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2b1 on 2025-02-28 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'password', + models.CharField(max_length=128, verbose_name='password'), + ), + ( + 'is_superuser', + models.BooleanField( + default=False, + help_text='Designates that this user has all permissions without explicitly assigning them.', + verbose_name='superuser status', + ), + ), + ('email', models.EmailField(max_length=120, unique=True)), + ('name', models.CharField(max_length=100)), + ('surname', models.CharField(max_length=120)), + ( + 'avatar_url', + models.URLField(blank=True, max_length=350, null=True), + ), + ('other', models.JSONField(default=dict)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('last_login', models.DateTimeField(blank=True, null=True)), + ( + 'groups', + models.ManyToManyField( + blank=True, + help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', + related_name='user_set', + related_query_name='user', + to='auth.group', + verbose_name='groups', + ), + ), + ( + 'user_permissions', + models.ManyToManyField( + blank=True, + help_text='Specific permissions for this user.', + related_name='user_set', + related_query_name='user', + to='auth.permission', + verbose_name='user permissions', + ), + ), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/promo_code/user/migrations/__init__.py b/promo_code/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/models.py b/promo_code/user/models.py new file mode 100644 index 0000000..225fb3b --- /dev/null +++ b/promo_code/user/models.py @@ -0,0 +1,65 @@ +import django.contrib.auth.models +import django.db.models +import django.utils.timezone + + +class UserManager(django.contrib.auth.models.BaseUserManager): + def create_user(self, email, name, surname, password=None, **extra_fields): + if not email: + raise ValueError('The Email must be set') + + email = self.normalize_email(email) + user = self.model( + email=email, + name=name, + surname=surname, + **extra_fields, + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser( + self, + email, + name, + surname, + password=None, + **extra_fields, + ): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + return self.create_user(email, name, surname, password, **extra_fields) + + +class User( + django.contrib.auth.models.AbstractBaseUser, + django.contrib.auth.models.PermissionsMixin, +): + email = django.db.models.EmailField(unique=True, max_length=120) + name = django.db.models.CharField(max_length=100) + surname = django.db.models.CharField(max_length=120) + avatar_url = django.db.models.URLField( + blank=True, + null=True, + max_length=350, + ) + other = django.db.models.JSONField(default=dict) + + is_active = django.db.models.BooleanField(default=True) + is_staff = django.db.models.BooleanField(default=False) + last_login = django.db.models.DateTimeField(null=True, blank=True) + + objects = UserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['name', 'surname'] + + def __str__(self): + return self.email + + def save(self, *args, **kwargs): + if not self.pk: + self.last_login = django.utils.timezone.now() + + super().save(*args, **kwargs) diff --git a/promo_code/user/serializers.py b/promo_code/user/serializers.py new file mode 100644 index 0000000..6debfff --- /dev/null +++ b/promo_code/user/serializers.py @@ -0,0 +1,106 @@ +import django.contrib.auth.password_validation +import django.core.exceptions +import django.core.validators +import rest_framework.exceptions +import rest_framework.serializers +import rest_framework.status +import rest_framework_simplejwt.tokens + +import user.models as user_models +import user.validators + + +class SignUpSerializer(rest_framework.serializers.ModelSerializer): + password = rest_framework.serializers.CharField( + write_only=True, + required=True, + validators=[django.contrib.auth.password_validation.validate_password], + max_length=60, + min_length=8, + style={'input_type': 'password'}, + ) + name = rest_framework.serializers.CharField(required=True, min_length=1) + surname = rest_framework.serializers.CharField(required=True, min_length=1) + email = rest_framework.serializers.EmailField( + required=True, + min_length=8, + validators=[ + user.validators.UniqueEmailValidator( + 'This email address is already registered.', + 'email_conflict', + ), + ], + ) + avatar_url = rest_framework.serializers.CharField( + required=False, + max_length=350, + validators=[ + django.core.validators.URLValidator(schemes=['http', 'https']), + ], + ) + other = rest_framework.serializers.JSONField( + required=True, + validators=[user.validators.OtherFieldValidator()], + ) + + class Meta: + model = user_models.User + fields = ( + 'name', + 'surname', + 'email', + 'avatar_url', + 'other', + 'password', + ) + + def create(self, validated_data): + try: + user = user_models.User.objects.create_user( + email=validated_data['email'], + name=validated_data['name'], + surname=validated_data['surname'], + avatar_url=validated_data.get('avatar_url'), + other=validated_data['other'], + password=validated_data['password'], + ) + return user + except django.core.exceptions.ValidationError as e: + raise rest_framework.serializers.ValidationError(e.messages) + + +class SignInSerializer(rest_framework.serializers.Serializer): + email = rest_framework.serializers.EmailField(required=True) + password = rest_framework.serializers.CharField( + required=True, + write_only=True, + ) + + def validate(self, data): + email = data.get('email') + password = data.get('password') + + if not email or not password: + raise rest_framework.serializers.ValidationError( + {'status': 'error', 'message': 'Both fields are required.'}, + code='required', + ) + + user = django.contrib.auth.authenticate( + request=self.context.get('request'), + email=email, + password=password, + ) + if not user: + raise rest_framework.exceptions.AuthenticationFailed( + {'status': 'error', 'message': 'Invalid email or password.'}, + code='authorization', + ) + + data['user'] = user + return data + + def get_token(self): + user = self.validated_data['user'] + refresh = rest_framework_simplejwt.tokens.RefreshToken.for_user(user) + return {'token': str(refresh.access_token)} diff --git a/promo_code/user/tests.py b/promo_code/user/tests.py new file mode 100644 index 0000000..93800b9 --- /dev/null +++ b/promo_code/user/tests.py @@ -0,0 +1,391 @@ +import django.test +import django.urls +import parameterized +import rest_framework.status +import rest_framework.test + +import user.models +import user.tokens + + +class AuthTestCase(rest_framework.test.APITestCase): + def setUp(self): + self.client = rest_framework.test.APIClient() + super(AuthTestCase, self).setUp() + + def tearDown(self): + user.models.User.objects.all().delete() + super(AuthTestCase, self).tearDown() + + def test_valid_registration_and_email_duplication(self): + # Successful registration + valid_data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'emmat1@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + valid_data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_200_OK, + ) + + # Duplicate email registration attempt + duplicate_data = { + 'name': 'Lui', + 'surname': 'Jomalone', + 'email': 'emmat1@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 14, 'country': 'fr'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + duplicate_data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_409_CONFLICT, + ) + + def test_invalid_email_format(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.fan', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_weak_password_common_phrase(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'whereismymoney777', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_weak_password_missing_special_char(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'fioejifojfieoAAAA9299', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_weak_password_too_short(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'Aa7$b!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def generate_test_cases(): + invalid_urls = [ + 'itsnotalink', + 'itsnotalinkjpeg', + 'https://', + 'grpc://', + '', + ] + return [ + (f'url_{i}', url, f'{i}dota.for.fan@gmail.com') + for i, url in enumerate(invalid_urls) + ] + + @parameterized.parameterized.expand(generate_test_cases()) + def test_invalid_avatar_urls(self, name, url, email): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': email, + 'password': 'SuperStrongPassword2000!', + 'avatar_url': url, + 'other': {'age': 23, 'country': 'us'}, + } + + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + msg=f'Failed for URL: {url}', + ) + + def test_missing_country_field(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_invalid_age_type(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': '23aaaaaa', 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_missing_age_field(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_negative_age_value(self): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'dota.for.fan@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': -20, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + @parameterized.parameterized.expand( + [ + ('empty_domain', '@'), + ('no_at_symbol', 'dota'), + ('missing_username', '@gmail.com'), + ('missing_domain_part', 'gmail.com'), + ], + ) + def test_invalid_email_formats(self, name, email): + data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': email, + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + msg=f'Failed for email: {email}', + ) + + def test_empty_name_field(self): + data = { + 'name': '', + 'surname': 'Thompson', + 'email': 'gogogonow@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_empty_surname_field(self): + data = { + 'name': 'Emma', + 'surname': '', + 'email': 'gogogonow@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + +class AuthFlowTestCase(rest_framework.test.APITestCase): + def setUp(self): + self.client = rest_framework.test.APIClient() + super(AuthFlowTestCase, self).setUp() + + def tearDown(self): + user.models.User.objects.all().delete() + super(AuthFlowTestCase, self).tearDown() + + def test_valid_registration(self): + data = { + 'name': 'Steve', + 'surname': 'Jobs', + 'email': 'minecraft.digger@gmail.com', + 'password': 'SuperStrongPassword2000!', + 'other': {'age': 23, 'country': 'gb'}, + } + response = self.client.post( + django.urls.reverse('api-user:sign-up'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_200_OK, + ) + self.assertIn('token', response.data) + self.assertTrue( + user.models.User.objects.filter( + email='minecraft.digger@gmail.com', + ).exists(), + ) + + def test_signin_missing_fields(self): + response = self.client.post( + django.urls.reverse('api-user:sign-in'), + {}, # Empty data + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_signin_invalid_password(self): + user.models.User.objects.create_user( + email='minecraft.digger@gmail.com', + name='Steve', + surname='Jobs', + password='SuperStrongPassword2000!', + other={'age': 23, 'country': 'gb'}, + ) + + data = { + 'email': 'minecraft.digger@gmail.com', + 'password': 'SuperInvalidPassword2000!', + } + response = self.client.post( + django.urls.reverse('api-user:sign-in'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_401_UNAUTHORIZED, + ) + + def test_signin_success(self): + user.models.User.objects.create_user( + email='minecraft.digger@gmail.com', + name='Steve', + surname='Jobs', + password='SuperStrongPassword2000!', + other={'age': 23, 'country': 'gb'}, + ) + + data = { + 'email': 'minecraft.digger@gmail.com', + 'password': 'SuperStrongPassword2000!', + } + response = self.client.post( + django.urls.reverse('api-user:sign-in'), + data, + format='json', + ) + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_200_OK, + ) diff --git a/promo_code/user/tokens.py b/promo_code/user/tokens.py new file mode 100644 index 0000000..fda698e --- /dev/null +++ b/promo_code/user/tokens.py @@ -0,0 +1,9 @@ +import rest_framework_simplejwt.tokens + + +class CustomAccessToken(rest_framework_simplejwt.tokens.AccessToken): + @classmethod + def for_user(cls, user): + token = super().for_user(user) + token['last_login'] = user.last_login.isoformat() + return token diff --git a/promo_code/user/urls.py b/promo_code/user/urls.py new file mode 100644 index 0000000..36ece70 --- /dev/null +++ b/promo_code/user/urls.py @@ -0,0 +1,19 @@ +import django.urls + +import user.views + +app_name = 'api-user' + + +urlpatterns = [ + django.urls.path( + 'user/auth/sign-up', + user.views.SignUpView.as_view(), + name='sign-up', + ), + django.urls.path( + 'user/auth/sign-in', + user.views.SignInView.as_view(), + name='sign-in', + ), +] diff --git a/promo_code/user/validators.py b/promo_code/user/validators.py new file mode 100644 index 0000000..feda79a --- /dev/null +++ b/promo_code/user/validators.py @@ -0,0 +1,71 @@ +import pycountry +import rest_framework.exceptions + +import user.models + + +class UniqueEmailValidator: + def __init__(self, default_detail=None, default_code=None): + self.status_code = 409 + self.default_detail = ( + default_detail or 'This email address is already registered.' + ) + self.default_code = default_code or 'email_conflict' + + def __call__(self, value): + if user.models.User.objects.filter(email=value).exists(): + exc = rest_framework.exceptions.APIException( + detail={ + 'status': 'error', + 'message': self.default_detail, + 'code': self.default_code, + }, + ) + exc.status_code = self.status_code + raise exc + + +class OtherFieldValidator: + """ + Validator for JSON fields containing: + - age (required, integer between 0 and 100) + - country (required, string with an ISO 3166-1 alpha-2 country code) + """ + + error_messages = { + 'invalid_type': 'Must be a JSON object.', + 'missing_field': 'This field is required.', + 'age_type': 'Must be an integer.', + 'age_range': 'Must be between 0 and 100.', + 'country_format': 'Must be a 2-letter ISO code.', + 'country_invalid': 'Invalid ISO 3166-1 alpha-2 country code.', + } + + def __call__(self, value): + if not isinstance(value, dict): + raise rest_framework.exceptions.ValidationError( + self.error_messages['invalid_type'], + ) + + errors = {} + + # Validate the 'age' field + age = value.get('age') + if age is None: + errors['age'] = self.error_messages['missing_field'] + elif not isinstance(age, int): + errors['age'] = self.error_messages['age_type'] + elif not (0 <= age <= 100): + errors['age'] = self.error_messages['age_range'] + + # Validate the 'country' field + country_code = value.get('country') + if country_code is None: + errors['country'] = self.error_messages['missing_field'] + elif not (isinstance(country_code, str) and len(country_code) == 2): + errors['country'] = self.error_messages['country_format'] + elif not pycountry.countries.get(alpha_2=country_code.upper()): + errors['country'] = self.error_messages['country_invalid'] + + if errors: + raise rest_framework.exceptions.ValidationError(errors) diff --git a/promo_code/user/views.py b/promo_code/user/views.py new file mode 100644 index 0000000..b8d65ec --- /dev/null +++ b/promo_code/user/views.py @@ -0,0 +1,61 @@ +import rest_framework.exceptions +import rest_framework.generics +import rest_framework.response +import rest_framework.serializers +import rest_framework.status +import rest_framework_simplejwt.tokens +import rest_framework_simplejwt.views + +import user.serializers + + +class BaseCustomResponseMixin: + error_response = {'status': 'error', 'message': 'Error in request data.'} + + def handle_validation_error(self): + return rest_framework.response.Response( + self.error_response, + status=rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + +class SignUpView( + BaseCustomResponseMixin, + rest_framework.generics.CreateAPIView, +): + serializer_class = user.serializers.SignUpSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except rest_framework.exceptions.ValidationError: + return self.handle_validation_error() + + user = serializer.save() + refresh = rest_framework_simplejwt.tokens.RefreshToken.for_user(user) + return rest_framework.response.Response( + {'token': str(refresh.access_token)}, + status=rest_framework.status.HTTP_200_OK, + ) + + +class SignInView( + BaseCustomResponseMixin, + rest_framework_simplejwt.views.TokenViewBase, +): + serializer_class = user.serializers.SignInSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + except rest_framework.serializers.ValidationError: + return self.handle_validation_error() + + return rest_framework.response.Response( + serializer.get_token(), + status=rest_framework.status.HTTP_200_OK, + ) diff --git a/requirements/prod.txt b/requirements/prod.txt index 0ab1727..9e2c2a1 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -2,5 +2,6 @@ django==5.2b1 djangorestframework==3.15.2 djangorestframework-simplejwt==5.4.0 gunicorn==23.0.0 +pycountry==24.6.1 python-dotenv==1.0.1 psycopg2-binary==2.9.10 diff --git a/requirements/test.txt b/requirements/test.txt index 234f255..49553db 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,2 +1,3 @@ -r flake8-plugins.txt -django-debug-toolbar==4.4.6 \ No newline at end of file +django-debug-toolbar==4.4.6 +parameterized==0.9.0