From 20e6e512d2aba944fb4b9f0e1d83d1a40acfe4d4 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 Mar 2024 18:13:59 +0300 Subject: [PATCH] Added updatePassword view, added exception handler, added friends view --- solution/pulse/api/users/authentication.py | 10 +-- .../api/users/migrations/0001_initial.py | 26 +++++- solution/pulse/api/users/models.py | 20 ++++- solution/pulse/api/users/serializers.py | 33 ++++++- solution/pulse/api/users/urls.py | 16 +++- solution/pulse/api/users/views.py | 89 ++++++++++++++----- solution/pulse/pulse/middleware.py | 14 --- solution/pulse/pulse/settings.py | 7 +- solution/pulse/pulse/utils.py | 11 +++ 9 files changed, 169 insertions(+), 57 deletions(-) delete mode 100644 solution/pulse/pulse/middleware.py create mode 100644 solution/pulse/pulse/utils.py diff --git a/solution/pulse/api/users/authentication.py b/solution/pulse/api/users/authentication.py index d9841c9..72241c8 100644 --- a/solution/pulse/api/users/authentication.py +++ b/solution/pulse/api/users/authentication.py @@ -15,15 +15,13 @@ class JWTAuthentication(BaseAuthentication): return "Provide a valid token in the 'Authorization' header" def authenticate(self, request): + if not IsAuthenticated in getattr(request.resolver_match.func.cls, "permission_classes", []): + return None + 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 + raise NotAuthenticated try: payload = jwt.decode( diff --git a/solution/pulse/api/users/migrations/0001_initial.py b/solution/pulse/api/users/migrations/0001_initial.py index cc77166..6d27659 100644 --- a/solution/pulse/api/users/migrations/0001_initial.py +++ b/solution/pulse/api/users/migrations/0001_initial.py @@ -1,8 +1,9 @@ -# Generated by Django 4.2.10 on 2024-03-03 06:12 +# Generated by Django 4.2.10 on 2024-03-03 15:01 import api.users.validators import django.core.validators from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -13,6 +14,13 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Friendship', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('addedAt', models.DateTimeField(auto_now_add=True)), + ], + ), migrations.CreateModel( name='Profile', fields=[ @@ -22,9 +30,19 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=100, validators=[django.core.validators.RegexValidator('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{6,100}$')])), ('countryCode', models.CharField(max_length=2, validators=[django.core.validators.RegexValidator('[a-zA-Z]{2}'), api.users.validators.CountryCodeValidator()])), ('isPublic', models.BooleanField()), - ('phone', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.MaxLengthValidator(20), django.core.validators.RegexValidator('\\+[\\d]+')])), - ('image', models.URLField(blank=True, null=True)), - ('friends', models.ManyToManyField(blank=True, to='users.profile')), + ('phone', models.CharField(max_length=20, null=True, validators=[django.core.validators.MaxLengthValidator(20), django.core.validators.RegexValidator('\\+[\\d]+')])), + ('image', models.URLField(null=True)), + ('friends', models.ManyToManyField(blank=True, through='users.Friendship', to='users.profile')), ], ), + migrations.AddField( + model_name='friendship', + name='from_profile', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendship_from', to='users.profile'), + ), + migrations.AddField( + model_name='friendship', + name='to_profile', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendship_to', to='users.profile'), + ), ] diff --git a/solution/pulse/api/users/models.py b/solution/pulse/api/users/models.py index 80ad911..9e7a0c8 100644 --- a/solution/pulse/api/users/models.py +++ b/solution/pulse/api/users/models.py @@ -28,11 +28,12 @@ class Profile(models.Model): phone = models.CharField( max_length=20, validators=[MaxLengthValidator(20), RegexValidator(r"\+[\d]+")], - blank=True, null=True, ) - image = models.URLField(max_length=200, blank=True, null=True) - friends = models.ManyToManyField("self", blank=True, symmetrical=False) + image = models.URLField(max_length=200, null=True) + friends = models.ManyToManyField( + "self", through="Friendship", blank=True, symmetrical=False + ) def __str__(self): return self.login @@ -76,3 +77,16 @@ class Profile(models.Model): errors["phone"] = {"User with this phone already exists"} return errors + + +class Friendship(models.Model): + from_profile = models.ForeignKey( + Profile, related_name="friendship_from", on_delete=models.CASCADE + ) + to_profile = models.ForeignKey( + Profile, related_name="friendship_to", on_delete=models.CASCADE + ) + addedAt = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.from_profile} -> {self.to_profile}" diff --git a/solution/pulse/api/users/serializers.py b/solution/pulse/api/users/serializers.py index 00f23ba..6d383b8 100644 --- a/solution/pulse/api/users/serializers.py +++ b/solution/pulse/api/users/serializers.py @@ -1,6 +1,7 @@ +from django.core.validators import RegexValidator from rest_framework import serializers -from api.users.models import Profile +from api.users.models import Friendship, Profile class ProfileSerializer(serializers.ModelSerializer): @@ -29,6 +30,14 @@ class UpdateProfileSerializer(serializers.ModelSerializer): "image", ] + def to_representation(self, instance): + data = super().to_representation(instance) + if data["image"] is None: + del data["image"] + if data["phone"] is None: + del data["phone"] + return data + class PublicProfileSerializer(serializers.ModelSerializer): class Meta: @@ -49,3 +58,25 @@ class PublicProfileSerializer(serializers.ModelSerializer): if data["phone"] is None: del data["phone"] return data + + +class FriendshipSerializer(serializers.ModelSerializer): + login = serializers.SerializerMethodField() + + class Meta: + model = Friendship + fields = ["login", "addedAt"] + + def get_login(self, obj): + return obj.to_profile.login + + +class PasswordChangeSerializer(serializers.Serializer): + # ruff: noqa: N815 + oldPassword = serializers.CharField(required=True) + newPassword = serializers.CharField( + required=True, + validators=[ + RegexValidator(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{6,100}$"), + ], + ) diff --git a/solution/pulse/api/users/urls.py b/solution/pulse/api/users/urls.py index 6e97349..fa74ac2 100644 --- a/solution/pulse/api/users/urls.py +++ b/solution/pulse/api/users/urls.py @@ -20,17 +20,27 @@ urlpatterns = [ ), path( "profiles/", - api.users.views.ProfileAPIView.as_view(), + api.users.views.ProfilesApiView.as_view(), name="profiles", ), path( "friends/add", - api.users.views.AddFriendAPIView.as_view(), + api.users.views.AddFriendApiView.as_view(), name="add-friend", ), path( "friends/remove", - api.users.views.RemoveFriendAPIView.as_view(), + api.users.views.RemoveFriendApiView.as_view(), name="remove-friend", ), + path( + "friends", + api.users.views.FriendsListApiView.as_view(), + name="friends-list", + ), + path( + "me/updatePassword", + api.users.views.PasswordChangeApiView.as_view(), + name="password-change", + ) ] diff --git a/solution/pulse/api/users/views.py b/solution/pulse/api/users/views.py index 31f7ab3..82f7316 100644 --- a/solution/pulse/api/users/views.py +++ b/solution/pulse/api/users/views.py @@ -5,13 +5,22 @@ import jwt from django.conf import settings from django.utils import timezone from rest_framework import status +from rest_framework.exceptions import ( + NotAuthenticated, + NotFound, + PermissionDenied, + ValidationError, +) +from rest_framework.generics import ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from api.users.models import Profile +from api.users.models import Friendship, Profile from api.users.permissions import CanAccessProfile from api.users.serializers import ( + FriendshipSerializer, + PasswordChangeSerializer, ProfileSerializer, PublicProfileSerializer, UpdateProfileSerializer, @@ -25,7 +34,9 @@ class RegisterUserApiView(APIView): if serializer.is_valid(): errors = Profile.check_unique(None, serializer.validated_data) if errors: - return Response(errors, status=status.HTTP_409_CONFLICT) + return Response( + {"reason:": str(errors)}, status=status.HTTP_409_CONFLICT + ) password = serializer.validated_data["password"] password_hash = bcrypt.hashpw( @@ -50,7 +61,7 @@ class RegisterUserApiView(APIView): return Response(profile, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + raise ValidationError(serializer.errors) class SigninUserApiView(APIView): @@ -63,14 +74,12 @@ class SigninUserApiView(APIView): if not bcrypt.checkpw( password.encode("utf-8"), user.password.encode("utf-8") ): - return Response( + raise NotAuthenticated( {"error": "Invalid credentials"}, - status=status.HTTP_401_UNAUTHORIZED, ) else: - return Response( + raise NotAuthenticated( {"error": "Invalid credentials"}, - status=status.HTTP_401_UNAUTHORIZED, ) token = jwt.encode( @@ -102,10 +111,14 @@ class ProfileMeApiView(APIView): if serializer.is_valid(): errors = Profile.check_unique(user.id, serializer.validated_data) if errors: - return Response(errors, status=status.HTTP_409_CONFLICT) + return Response( + {"reason:": str(errors)}, status=status.HTTP_409_CONFLICT + ) serializer.save() + return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + raise ValidationError(serializer.errors) def _get_profile_data(self, user): profile = { @@ -123,7 +136,7 @@ class ProfileMeApiView(APIView): return profile -class ProfileAPIView(APIView): +class ProfilesApiView(APIView): permission_classes = [IsAuthenticated, CanAccessProfile] def get(self, request, login): @@ -133,13 +146,12 @@ class ProfileAPIView(APIView): serializer = PublicProfileSerializer(profile) return Response(serializer.data) except Profile.DoesNotExist: - return Response( + raise PermissionDenied( {"detail": "Profile not found."}, - status=status.HTTP_403_FORBIDDEN, - ) + ) from None -class AddFriendAPIView(APIView): +class AddFriendApiView(APIView): permission_classes = [IsAuthenticated] def post(self, request): @@ -152,13 +164,12 @@ class AddFriendAPIView(APIView): status=status.HTTP_200_OK, ) except Profile.DoesNotExist: - return Response( + raise NotFound( {"detail": "Profile not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + ) from None -class RemoveFriendAPIView(APIView): +class RemoveFriendApiView(APIView): permission_classes = [IsAuthenticated] def post(self, request): @@ -171,7 +182,43 @@ class RemoveFriendAPIView(APIView): status=status.HTTP_200_OK, ) except Profile.DoesNotExist: - return Response( + raise NotFound( {"detail": "Profile not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + ) from None + + +class FriendsListApiView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = FriendshipSerializer + + def get_queryset(self): + limit = int(self.request.query_params.get("limit", 5)) + offset = int(self.request.query_params.get("offset", 0)) + + return Friendship.objects.filter(from_profile=self.request.user)[ + offset : offset + limit + ] + + +class PasswordChangeApiView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = PasswordChangeSerializer(data=request.data) + + if serializer.is_valid(): + old_password = serializer.validated_data.get("oldPassword") + new_password = serializer.validated_data.get("newPassword") + + if bcrypt.checkpw(old_password.encode("utf-8"), request.user.password.encode("utf-8")): + password_hash = bcrypt.hashpw( + new_password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + request.user.password = password_hash + request.user.save() + + return Response({"status": "ok"}, status=status.HTTP_200_OK) + + raise PermissionDenied({"error": "Invalid old password"}) + + raise ValidationError(serializer.errors) diff --git a/solution/pulse/pulse/middleware.py b/solution/pulse/pulse/middleware.py deleted file mode 100644 index 1cf02b8..0000000 --- a/solution/pulse/pulse/middleware.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.http import JsonResponse -from rest_framework import status - - -class ErrorResponseMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - if response.status_code >= status.HTTP_400_BAD_REQUEST: - response.data = {"reason": response.data} - response = JsonResponse(response.data, status=response.status_code) - return response diff --git a/solution/pulse/pulse/settings.py b/solution/pulse/pulse/settings.py index 4a7010f..ff16111 100644 --- a/solution/pulse/pulse/settings.py +++ b/solution/pulse/pulse/settings.py @@ -38,8 +38,6 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - # Developed middleware - "pulse.middleware.ErrorResponseMiddleware", ] ROOT_URLCONF = "pulse.urls" @@ -114,15 +112,14 @@ STATIC_ROOT = BASE_DIR / "static" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" REST_FRAMEWORK = { - "DEFAULT_RENDERER_CLASSES": [ - "rest_framework.renderers.JSONRenderer", - ], "DEFAULT_FILTER_BACKENDS": [ "django_filters.rest_framework.DjangoFilterBackend" ], "DEFAULT_AUTHENTICATION_CLASSES": ( "api.users.authentication.JWTAuthentication", ), + "EXCEPTION_HANDLER": "pulse.utils.wrap_error_into_reason", + "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%SZ", } APPEND_SLASH = False diff --git a/solution/pulse/pulse/utils.py b/solution/pulse/pulse/utils.py new file mode 100644 index 0000000..252ac8b --- /dev/null +++ b/solution/pulse/pulse/utils.py @@ -0,0 +1,11 @@ +from rest_framework.views import exception_handler + + +def wrap_error_into_reason(exc, context): + response = exception_handler(exc, context) + + if response is not None: + custom_response = {"reason": str(response.data)} + response.data = custom_response + + return response