From 8f3499a7ae010d713cc0a5378d8150a383c48215 Mon Sep 17 00:00:00 2001 From: yusufaygun Date: Sat, 14 Dec 2024 14:23:10 +0300 Subject: [PATCH] Add follow/unfollow functionality with tests and Swagger docs - Follow model to store follower-following relationships is created - Toggle follow endpoint for users is created - Unit tests for following, unfollowing, and unauthenticated access are created - Swagger documentation for the follow/unfollow endpoint is written --- .../apps/accounts/migrations/0002_follow.py | 27 +++++++++ app/backend/v1/apps/accounts/models.py | 12 ++++ app/backend/v1/apps/accounts/tests.py | 33 ++++++++++- app/backend/v1/apps/accounts/urls.py | 1 + app/backend/v1/apps/accounts/views.py | 57 ++++++++++++++++++- 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 app/backend/v1/apps/accounts/migrations/0002_follow.py diff --git a/app/backend/v1/apps/accounts/migrations/0002_follow.py b/app/backend/v1/apps/accounts/migrations/0002_follow.py new file mode 100644 index 0000000..f372d4b --- /dev/null +++ b/app/backend/v1/apps/accounts/migrations/0002_follow.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.4 on 2024-12-14 11:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Follow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('follower', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL)), + ('following', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('follower', 'following')}, + }, + ), + ] diff --git a/app/backend/v1/apps/accounts/models.py b/app/backend/v1/apps/accounts/models.py index 7a2b2e3..45632c3 100644 --- a/app/backend/v1/apps/accounts/models.py +++ b/app/backend/v1/apps/accounts/models.py @@ -3,3 +3,15 @@ class CustomUser(AbstractUser): pass + +class Follow(models.Model): + follower = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="following") + following = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="followers") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('follower', 'following') # Each follower-following pair is unique + + def __str__(self): + return f"{self.follower.username} follows {self.following.username}" + diff --git a/app/backend/v1/apps/accounts/tests.py b/app/backend/v1/apps/accounts/tests.py index 202b64e..c05faac 100644 --- a/app/backend/v1/apps/accounts/tests.py +++ b/app/backend/v1/apps/accounts/tests.py @@ -1,7 +1,9 @@ from django.urls import reverse -from rest_framework.test import APITestCase +from rest_framework.test import APITestCase, APIClient from rest_framework import status -from v1.apps.accounts.models import CustomUser +from v1.apps.accounts.models import CustomUser, Follow +from django.test import TestCase + class AccountsTests(APITestCase): def setUp(self): @@ -79,3 +81,30 @@ def test_login_nonexistent_user(self): response = self.client.post(self.login_url, login_data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("Invalid credentials", response.data['error']) + +class FollowUserTest(TestCase): + def setUp(self): + self.client = APIClient() + self.follower = CustomUser.objects.create_user(username="follower", password="password") + self.following = CustomUser.objects.create_user(username="following", password="password") + self.url = reverse('toggle-follow', args=[self.following.id]) + + def test_follow_user_authenticated(self): + self.client.force_authenticate(user=self.follower) + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + follow_exists = Follow.objects.filter(follower=self.follower, following=self.following).exists() + self.assertTrue(follow_exists) + + def test_unfollow_user_authenticated(self): + self.client.force_authenticate(user=self.follower) + self.client.post(self.url) # Follow first + response = self.client.post(self.url) # Then unfollow + self.assertEqual(response.status_code, status.HTTP_200_OK) + follow_exists = Follow.objects.filter(follower=self.follower, following=self.following).exists() + self.assertFalse(follow_exists) + + def test_follow_user_unauthenticated(self): + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + diff --git a/app/backend/v1/apps/accounts/urls.py b/app/backend/v1/apps/accounts/urls.py index 9052056..fff35c3 100644 --- a/app/backend/v1/apps/accounts/urls.py +++ b/app/backend/v1/apps/accounts/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path('signup/', views.sign_up, name='sign-up'), path('login/', views.login, name='login'), + path('/follow/', views.toggle_follow, name='toggle-follow'), ] \ No newline at end of file diff --git a/app/backend/v1/apps/accounts/views.py b/app/backend/v1/apps/accounts/views.py index 56b6756..55d06c1 100644 --- a/app/backend/v1/apps/accounts/views.py +++ b/app/backend/v1/apps/accounts/views.py @@ -7,6 +7,9 @@ from drf_yasg import openapi from rest_framework.permissions import AllowAny from rest_framework.decorators import permission_classes +from v1.apps.accounts.models import CustomUser, Follow +from rest_framework.permissions import IsAuthenticated + User = get_user_model() # Sign-up view @@ -90,4 +93,56 @@ def login(request): 'refresh_token': str(refresh) }, status=status.HTTP_200_OK) else: - return Response({"error": "Invalid credentials"}, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response({"error": "Invalid credentials"}, status=status.HTTP_400_BAD_REQUEST) + + +@swagger_auto_schema( + method='post', + operation_description="Toggle follow/unfollow for a specific user. If the user is not followed, the current user will follow them. If the user is already followed, the follow will be removed.", + operation_summary="Toggle Follow on a User", + responses={ + 201: openapi.Response( + description="User followed successfully", + examples={ + 'application/json': { + 'message': 'User followed successfully' + } + } + ), + 200: openapi.Response( + description="User unfollowed successfully", + examples={ + 'application/json': { + 'message': 'User unfollowed successfully' + } + } + ), + 404: openapi.Response( + description="User not found", + examples={ + 'application/json': { + 'error': 'User not found' + } + } + ) + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def toggle_follow(request, user_id): + try: + following_user = CustomUser.objects.get(id=user_id) + except CustomUser.DoesNotExist: + return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) + + follower = request.user + if follower == following_user: + return Response({"error": "You cannot follow yourself"}, status=status.HTTP_400_BAD_REQUEST) + + follow_instance, created = Follow.objects.get_or_create(follower=follower, following=following_user) + + if not created: + follow_instance.delete() + return Response({"message": "User unfollowed successfully"}, status=status.HTTP_200_OK) + + return Response({"message": "User followed successfully"}, status=status.HTTP_201_CREATED)