From 84b245ecf26af3ad9e97e7ace8aef2711e49ca7e Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 2 Mar 2024 12:32:01 +0300 Subject: [PATCH] Added validation for countries, added patch method for profile page, code improvements and small fixes --- solution/pulse/countries/views.py | 18 +++-- solution/pulse/pulse/settings.py | 4 +- solution/pulse/users/authentication.py | 17 ++++- .../pulse/users/migrations/0001_initial.py | 28 ------- solution/pulse/users/models.py | 40 ++++++++-- solution/pulse/users/serializers.py | 17 ++++- solution/pulse/users/validators.py | 16 ++++ solution/pulse/users/views.py | 74 ++++++------------- 8 files changed, 115 insertions(+), 99 deletions(-) delete mode 100644 solution/pulse/users/migrations/0001_initial.py create mode 100644 solution/pulse/users/validators.py diff --git a/solution/pulse/countries/views.py b/solution/pulse/countries/views.py index 32b8c1a..7ddef29 100644 --- a/solution/pulse/countries/views.py +++ b/solution/pulse/countries/views.py @@ -1,4 +1,5 @@ -from django.db.models import Q +from django.conf import settings +from rest_framework.exceptions import ValidationError from rest_framework.generics import ListAPIView, RetrieveAPIView from countries.models import Country @@ -13,10 +14,17 @@ class CountryListView(ListAPIView): regions = self.request.query_params.get("region") if regions: regions_list = regions.split(",") - query = Q() - for region in regions_list: - query |= Q(region=region) - queryset = queryset.filter(query) + invalid_regions = [ + region + for region in regions_list + if region not in settings.REGIONS + ] + if invalid_regions: + invalid_regions_str = ", ".join(invalid_regions) + error_message = f"Invalid region(s): {invalid_regions_str}" + raise ValidationError(error_message) + + queryset = queryset.filter(region__in=regions_list) return queryset diff --git a/solution/pulse/pulse/settings.py b/solution/pulse/pulse/settings.py index db8fead..2fdc524 100644 --- a/solution/pulse/pulse/settings.py +++ b/solution/pulse/pulse/settings.py @@ -95,6 +95,8 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +REGIONS = ["Europe", "Africa", "Americas", "Oceania", "Asia"] + LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" @@ -115,8 +117,6 @@ REST_FRAMEWORK = { ], "DEFAULT_AUTHENTICATION_CLASSES": ( "users.authentication.JWTAuthentication", - "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.BasicAuthentication", ), } diff --git a/solution/pulse/users/authentication.py b/solution/pulse/users/authentication.py index 5e41c6c..de0387a 100644 --- a/solution/pulse/users/authentication.py +++ b/solution/pulse/users/authentication.py @@ -1,16 +1,27 @@ import jwt from django.conf import settings -from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.authentication import ( + BaseAuthentication, +) +from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated +from rest_framework.permissions import IsAuthenticated from users.models import Profile class JWTAuthentication(BaseAuthentication): + def authenticate_header(self, request): + return "Provide a valid token in the 'Authorization' header" + def authenticate(self, request): token = request.headers.get("Authorization", "").split("Bearer ")[-1] if not token: + if IsAuthenticated in getattr( + request.resolver_match.func.cls, "permission_classes", [] + ): + raise NotAuthenticated + return None try: @@ -18,7 +29,7 @@ class JWTAuthentication(BaseAuthentication): token, settings.SECRET_KEY, algorithms=["HS256"] ) - user = Profile.objects.get(login=payload["login"]) + user = Profile.objects.get(id=payload["id"]) except Profile.DoesNotExist: error = "Invalid token" raise AuthenticationFailed(error) from None diff --git a/solution/pulse/users/migrations/0001_initial.py b/solution/pulse/users/migrations/0001_initial.py deleted file mode 100644 index 8c01d03..0000000 --- a/solution/pulse/users/migrations/0001_initial.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.10 on 2024-02-29 14:37 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Profile', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('login', models.CharField(max_length=30, validators=[django.core.validators.RegexValidator('[a-zA-Z0-9-]+')])), - ('email', models.EmailField(max_length=50)), - ('password', models.CharField(max_length=100, validators=[django.core.validators.MinLengthValidator(6), django.core.validators.MaxLengthValidator(100), django.core.validators.RegexValidator('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).+$')])), - ('countryCode', models.CharField(max_length=2, validators=[django.core.validators.RegexValidator('[a-zA-Z]{2}')])), - ('isPublic', models.BooleanField()), - ('phone', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator('\\+[\\d]+')])), - ('image', models.URLField(blank=True, null=True)), - ], - ), - ] diff --git a/solution/pulse/users/models.py b/solution/pulse/users/models.py index 0ca42a4..b52e273 100644 --- a/solution/pulse/users/models.py +++ b/solution/pulse/users/models.py @@ -1,34 +1,33 @@ from django.core.validators import ( MaxLengthValidator, - MinLengthValidator, RegexValidator, ) from django.db import models +from users.validators import CountryCodeValidator + class Profile(models.Model): login = models.CharField( max_length=30, - validators=[RegexValidator(r"[a-zA-Z0-9-]+")], + validators=[RegexValidator(r"^[a-zA-Z0-9-]+$")], ) email = models.EmailField(max_length=50) password = models.CharField( max_length=100, validators=[ - MinLengthValidator(6), - MaxLengthValidator(100), - RegexValidator(r"^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).+$"), + RegexValidator(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{6,100}$"), ], ) # ruff: noqa: DJ001 N815 countryCode = models.CharField( max_length=2, - validators=[RegexValidator(r"[a-zA-Z]{2}")], + validators=[RegexValidator(r"[a-zA-Z]{2}"), CountryCodeValidator()], ) isPublic = models.BooleanField() phone = models.CharField( max_length=20, - validators=[RegexValidator(r"\+[\d]+")], + validators=[MaxLengthValidator(20), RegexValidator(r"\+[\d]+")], blank=True, null=True, ) @@ -39,3 +38,30 @@ class Profile(models.Model): def is_authenticated(self): return True + + @classmethod + def check_unique(cls, user_id, validated_data): + errors = {} + + if ( + cls.objects.filter(login=validated_data.get("login")) + .exclude(id=user_id) + .exists() + ): + errors["login"] = {"User with this login already exists"} + + if ( + cls.objects.filter(email=validated_data.get("email")) + .exclude(id=user_id) + .exists() + ): + errors["email"] = {"User with this email already exists"} + + if ( + cls.objects.filter(phone=validated_data.get("phone")) + .exclude(id=user_id) + .exists() + ): + errors["phone"] = {"User with this phone already exists"} + + return errors diff --git a/solution/pulse/users/serializers.py b/solution/pulse/users/serializers.py index 3697084..3bc7e60 100644 --- a/solution/pulse/users/serializers.py +++ b/solution/pulse/users/serializers.py @@ -3,9 +3,7 @@ from rest_framework import serializers from users.models import Profile -class UserSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True) - +class ProfileSerializer(serializers.ModelSerializer): class Meta: model = Profile fields = [ @@ -17,3 +15,16 @@ class UserSerializer(serializers.ModelSerializer): "phone", "image", ] + + +class UpdateProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = [ + "login", + "email", + "countryCode", + "isPublic", + "phone", + "image", + ] diff --git a/solution/pulse/users/validators.py b/solution/pulse/users/validators.py new file mode 100644 index 0000000..fc550f0 --- /dev/null +++ b/solution/pulse/users/validators.py @@ -0,0 +1,16 @@ +from countries.models import Country +from django.core.exceptions import ValidationError +from django.core.validators import BaseValidator +from django.utils.deconstruct import deconstructible + + +@deconstructible +class CountryCodeValidator(BaseValidator): + def __init__(self, message=None): + self.message = message or "There is no such country" + + def __call__(self, value): + try: + Country.objects.get(alpha2=value) + except Country.DoesNotExist: + raise ValidationError(self.message) from None diff --git a/solution/pulse/users/views.py b/solution/pulse/users/views.py index 39f8144..448297e 100644 --- a/solution/pulse/users/views.py +++ b/solution/pulse/users/views.py @@ -1,4 +1,3 @@ -import re from datetime import timedelta import bcrypt @@ -11,62 +10,19 @@ from rest_framework.response import Response from rest_framework.views import APIView from users.models import Profile -from users.serializers import UserSerializer - -MIN_PASSWORD_LEN = 6 -MAX_PASSWORD_LEN = 100 +from users.serializers import ProfileSerializer, UpdateProfileSerializer class RegisterUserApiView(APIView): def post(self, request): - serializer = UserSerializer(data=request.data) + serializer = ProfileSerializer(data=request.data) if serializer.is_valid(): - if ( - Profile.objects.filter( - login=serializer.validated_data["login"] - ).first() - is not None - ): - return Response( - {"error": "User with this login already exists"}, - status=status.HTTP_409_CONFLICT, - ) - if ( - Profile.objects.filter( - email=serializer.validated_data["email"] - ).first() - is not None - ): - return Response( - {"error": "User with this email already exists"}, - status=status.HTTP_409_CONFLICT, - ) - if ( - Profile.objects.filter( - phone=serializer.validated_data["phone"] - ).first() - is not None - ): - return Response( - {"error": "User with this phone already exists"}, - status=status.HTTP_409_CONFLICT, - ) + errors = Profile.check_unique(None, serializer.validated_data) + if errors: + return Response(errors, status=status.HTTP_409_CONFLICT) password = serializer.validated_data["password"] - password_pattern = re.compile( - r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{6,100}$" - ) - - if not (bool(re.match(password_pattern, password))): - error = { - "error": "Your password does not meet our requirements" - } - return Response( - error, - status=status.HTTP_400_BAD_REQUEST, - ) - password_hash = bcrypt.hashpw( password.encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") @@ -114,7 +70,7 @@ class SigninUserApiView(APIView): token = jwt.encode( { - "login": login, + "id": user.id, "password": password, "exp": timezone.now() + timedelta(hours=24), }, @@ -130,7 +86,23 @@ class ProfileMeApiView(APIView): def get(self, request): user = request.user + profile_data = self._get_profile_data(user) + return Response(profile_data) + def patch(self, request): + user = request.user + serializer = UpdateProfileSerializer( + user, data=request.data, partial=True + ) + if serializer.is_valid(): + errors = Profile.check_unique(user.id, serializer.validated_data) + if errors: + return Response(errors, status=status.HTTP_409_CONFLICT) + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def _get_profile_data(self, user): profile = { "login": user.login, "email": user.email, @@ -143,4 +115,4 @@ class ProfileMeApiView(APIView): if user.image is not None: profile["image"] = user.image - return Response(profile) + return profile