From 88def9bbe3f9245d66947e057a001b5e542aa69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20Emin=20=C3=87ift=C3=A7i?= <115792721+meminciftci@users.noreply.github.com> Date: Sat, 16 Nov 2024 17:20:28 +0300 Subject: [PATCH] feat(be): implement forum upvote and downvote endpoints and tests (#641) * feat(be): implement forum upvote modelviewset, serializer, model > > Co-authored-by: MucahitErdoganUnlu mucahit.719@hotmail.com * feat(be): implement forum downvote modelviewset, serializer, model, and unit tests > > Co-authored-by: MucahitErdoganUnlu mucahit.719@hotmail.com --- backend/core/migrations/0002_forumupvote.py | 28 +++ backend/core/migrations/0003_forumdownvote.py | 28 +++ backend/core/models.py | 36 +++ .../core/serializers/forum_vote_serializer.py | 40 ++++ .../core/tests/test_forum_upvote_downvote.py | 219 ++++++++++++++++++ backend/core/urls.py | 4 + backend/core/views/forum_vote_views.py | 29 +++ 7 files changed, 384 insertions(+) create mode 100644 backend/core/migrations/0002_forumupvote.py create mode 100644 backend/core/migrations/0003_forumdownvote.py create mode 100644 backend/core/serializers/forum_vote_serializer.py create mode 100644 backend/core/tests/test_forum_upvote_downvote.py create mode 100644 backend/core/views/forum_vote_views.py diff --git a/backend/core/migrations/0002_forumupvote.py b/backend/core/migrations/0002_forumupvote.py new file mode 100644 index 0000000..c83e649 --- /dev/null +++ b/backend/core/migrations/0002_forumupvote.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2024-11-16 13:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ForumUpvote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('forum_question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.forumquestion')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('user', 'forum_question')}, + }, + ), + ] diff --git a/backend/core/migrations/0003_forumdownvote.py b/backend/core/migrations/0003_forumdownvote.py new file mode 100644 index 0000000..60cc2c6 --- /dev/null +++ b/backend/core/migrations/0003_forumdownvote.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.2 on 2024-11-16 14:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_forumupvote'), + ] + + operations = [ + migrations.CreateModel( + name='ForumDownvote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('forum_question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.forumquestion')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('user', 'forum_question')}, + }, + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 2423e49..e8cfaaa 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -4,6 +4,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import ValidationError # Import ValidationError + class CustomUser(AbstractUser): email = models.EmailField() full_name = models.CharField(max_length=100) @@ -118,3 +119,38 @@ class Meta: def __str__(self): return f"{self.user} bookmarked {self.forum_question}" + +class ForumUpvote(models.Model): + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + forum_question = models.ForeignKey(ForumQuestion, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "forum_question") + ordering = ["-created_at"] + + def save(self, *args, **kwargs): + if ForumDownvote.objects.filter(user=self.user, forum_question=self.forum_question).exists(): + raise ValidationError("A user cannot upvote and downvote the same forum question at the same time.") + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.user} upvoted {self.forum_question}" + + +class ForumDownvote(models.Model): + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + forum_question = models.ForeignKey(ForumQuestion, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "forum_question") + ordering = ["-created_at"] + + def save(self, *args, **kwargs): + if ForumUpvote.objects.filter(user=self.user, forum_question=self.forum_question).exists(): + raise ValidationError("A user cannot upvote and downvote the same forum question at the same time.") + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.user} downvoted {self.forum_question}" diff --git a/backend/core/serializers/forum_vote_serializer.py b/backend/core/serializers/forum_vote_serializer.py new file mode 100644 index 0000000..672487d --- /dev/null +++ b/backend/core/serializers/forum_vote_serializer.py @@ -0,0 +1,40 @@ +from django.contrib.auth import get_user_model +from faker import Faker +from rest_framework import serializers + +from ..models import (ForumUpvote, ForumDownvote) + +User = get_user_model() +queryset = User.objects.all() + + +class ForumUpvoteSerializer(serializers.ModelSerializer): + class Meta: + model = ForumUpvote + fields = ("id", "user", "forum_question", "created_at") + read_only_fields = ("id", "user", "created_at") + + def validate(self, attrs): + user = self.context["request"].user + forum_question = attrs["forum_question"] + + if ForumUpvote.objects.filter(user=user, forum_question=forum_question).exists(): + raise serializers.ValidationError("You have already upvoted this forum question.") + + return attrs + + +class ForumDownvoteSerializer(serializers.ModelSerializer): + class Meta: + model = ForumDownvote + fields = ("id", "user", "forum_question", "created_at") + read_only_fields = ("id", "user", "created_at") + + def validate(self, attrs): + user = self.context["request"].user + forum_question = attrs["forum_question"] + + if ForumDownvote.objects.filter(user=user, forum_question=forum_question).exists(): + raise serializers.ValidationError("You have already downvoted this forum question.") + + return attrs diff --git a/backend/core/tests/test_forum_upvote_downvote.py b/backend/core/tests/test_forum_upvote_downvote.py new file mode 100644 index 0000000..c1e5578 --- /dev/null +++ b/backend/core/tests/test_forum_upvote_downvote.py @@ -0,0 +1,219 @@ +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.urls import reverse +from rest_framework_simplejwt.tokens import RefreshToken +from faker import Faker +from core.models import ForumUpvote, ForumDownvote, ForumQuestion +from django.contrib.auth import get_user_model + +User = get_user_model() +fake = Faker() + + +class ForumUpvoteAPITest(APITestCase): + def setUp(self): + # Create a test user + self.user = User.objects.create_user( + username=fake.user_name(), + password="testpassword", + email=fake.email(), + full_name=fake.name() + ) + + # Authenticate the test client + self.client = APIClient() + refresh = RefreshToken.for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {str(refresh.access_token)}') + + # Create a ForumQuestion + self.forum_question = ForumQuestion.objects.create( + title="Test Forum Question", + question="This is a test question for votes.", + author=self.user + ) + + # Vote data + self.data = {"forum_question": self.forum_question.id} + + def test_create_forum_upvote(self): + """Test creating a forum vote""" + # Send POST request to create a new vote + response = self.client.post(reverse('forum-upvote-list'), self.data, format='json') + + # Assertions + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(ForumUpvote.objects.filter(user=self.user, forum_question=self.forum_question).exists()) + self.assertIn("id", response.data) + self.assertIn("user", response.data) + self.assertIn("forum_question", response.data) + + def test_delete_forum_upvote(self): + """Test deleting a forum vote""" + # Create a vote to delete + self.client.post(reverse('forum-upvote-list'), self.data, format='json') + forum_vote = ForumUpvote.objects.get(user=self.user, forum_question=self.forum_question) + + # Send DELETE request to remove the vote + response = self.client.delete(reverse('forum-upvote-detail', args=[forum_vote.id])) + + # Assertions + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(ForumUpvote.objects.filter(id=forum_vote.id).exists()) + + def test_cannot_upvote_same_forum_question_twice(self): + """Test that the same forum question cannot be voted twice""" + # Create the first vote + self.client.post(reverse('forum-upvote-list'), self.data, format='json') + + # Attempt to create a duplicate vote + response = self.client.post(reverse('forum-upvote-list'), self.data, format='json') + + # Assertions + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + ForumUpvote.objects.filter(user=self.user, forum_question=self.forum_question).count(), + 1 # Ensure only one vote exists + ) + + def test_cannot_delete_other_users_upvote(self): + """Test that a user cannot delete another user's vote""" + # Create another user and their vote + other_user = User.objects.create_user( + username="otheruser", + password="otherpassword", + email="otheruser@example.com", + full_name="Other User" + ) + other_vote = ForumUpvote.objects.create(user=other_user, forum_question=self.forum_question) + + # Attempt to delete the other user's vote + response = self.client.delete(reverse('forum-upvote-detail', args=[other_vote.id])) + + # Assertions + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertTrue(ForumUpvote.objects.filter(id=other_vote.id).exists()) + + def test_get_list_upvote_pagination(self): + """Test that the upvotes are paginated""" + # Create 15 upvotes + # for _ in range(15): + ForumUpvote.objects.create(user=self.user, forum_question=self.forum_question) + + # Send GET request to retrieve the upvotes + response = self.client.get(reverse('forum-upvote-list')) + + # Assertions + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("count", response.data) + self.assertIn("next", response.data) + self.assertIn("previous", response.data) + self.assertIn("results", response.data) + self.assertIn("id", response.data['results'][0]) + self.assertIn("user", response.data['results'][0]) + self.assertIn("forum_question", response.data['results'][0]) + self.assertIn("created_at", response.data['results'][0]) + + +class ForumDownvoteAPITest(APITestCase): + def setUp(self): + # Create a test user + self.user = User.objects.create_user( + username=fake.user_name(), + password="testpassword", + email=fake.email(), + full_name=fake.name() + ) + + # Authenticate the test client + self.client = APIClient() + refresh = RefreshToken.for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {str(refresh.access_token)}') + + # Create a ForumQuestion + self.forum_question = ForumQuestion.objects.create( + title="Test Forum Question", + question="This is a test question for votes.", + author=self.user + ) + + # Vote data + self.data = {"forum_question": self.forum_question.id} + + def test_create_forum_downvote(self): + """Test creating a forum downvote""" + # Send POST request to create a new downvote + response = self.client.post(reverse('forum-downvote-list'), self.data, format='json') + + # Assertions + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(ForumDownvote.objects.filter(user=self.user, forum_question=self.forum_question).exists()) + self.assertIn("id", response.data) + self.assertIn("user", response.data) + self.assertIn("forum_question", response.data) + + def test_delete_forum_downvote(self): + """Test deleting a forum downvote""" + # Create a downvote to delete + self.client.post(reverse('forum-downvote-list'), self.data, format='json') + forum_vote = ForumDownvote.objects.get(user=self.user, forum_question=self.forum_question) + + # Send DELETE request to remove the downvote + response = self.client.delete(reverse('forum-downvote-detail', args=[forum_vote.id])) + + # Assertions + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(ForumDownvote.objects.filter(id=forum_vote.id).exists()) + + def test_cannot_downvote_same_forum_question_twice(self): + """Test that the same forum question cannot be downvoted twice""" + # Create the first downvote + self.client.post(reverse('forum-downvote-list'), self.data, format='json') + + # Attempt to create a duplicate downvote + response = self.client.post(reverse('forum-downvote-list'), self.data, format='json') + + # Assertions + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + ForumDownvote.objects.filter(user=self.user, forum_question=self.forum_question).count(), + 1 # Ensure only one downvote exists + ) + + def test_cannot_delete_other_users_downvote(self): + """Test that a user cannot delete another user's vote""" + # Create another user and their vote + other_user = User.objects.create_user( + username="otheruser", + password="otherpassword", + email="otheruser@example.com", + full_name="Other User" + ) + other_vote = ForumDownvote.objects.create(user=other_user, forum_question=self.forum_question) + + # Attempt to delete the other user's vote + response = self.client.delete(reverse('forum-downvote-detail', args=[other_vote.id])) + + # Assertions + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertTrue(ForumDownvote.objects.filter(id=other_vote.id).exists()) + + def test_get_list_downvote_pagination(self): + """Test that the upvotes are paginated""" + # Create 15 upvotes + # for _ in range(15): + ForumDownvote.objects.create(user=self.user, forum_question=self.forum_question) + + # Send GET request to retrieve the upvotes + response = self.client.get(reverse('forum-downvote-list')) + + # Assertions + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("count", response.data) + self.assertIn("next", response.data) + self.assertIn("previous", response.data) + self.assertIn("results", response.data) + self.assertIn("id", response.data['results'][0]) + self.assertIn("user", response.data['results'][0]) + self.assertIn("forum_question", response.data['results'][0]) + self.assertIn("created_at", response.data['results'][0]) + \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index 633ba5d..9dabf53 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -4,6 +4,8 @@ from .views.rate_quiz_views import RateQuizViewSet from .views.take_quiz_views import TakeQuizViewSet from .views.forum_bookmark_views import ForumBookmarkViewSet +from .views.forum_vote_views import ForumUpvoteViewSet +from .views.forum_vote_views import ForumDownvoteViewSet from rest_framework.routers import DefaultRouter from .views import views from .views.tagging_views import TaggingView @@ -33,6 +35,8 @@ router.register(r'rate-quiz', RateQuizViewSet, basename='rate-quiz') router.register(r'take-quiz', TakeQuizViewSet, basename='take-quiz') router.register(r'forum-bookmarks', ForumBookmarkViewSet, basename='forumbookmark') +router.register(r'forum-upvote', ForumUpvoteViewSet, basename='forum-upvote') +router.register(r'forum-downvote', ForumDownvoteViewSet, basename='forum-downvote') urlpatterns = [ path('admin/', admin.site.urls), diff --git a/backend/core/views/forum_vote_views.py b/backend/core/views/forum_vote_views.py new file mode 100644 index 0000000..90572d6 --- /dev/null +++ b/backend/core/views/forum_vote_views.py @@ -0,0 +1,29 @@ +from rest_framework import viewsets, permissions +from ..models import ForumUpvote, ForumDownvote +from ..serializers.forum_vote_serializer import ForumUpvoteSerializer, ForumDownvoteSerializer + + +class ForumUpvoteViewSet(viewsets.ModelViewSet): + queryset = ForumUpvote.objects.all() + serializer_class = ForumUpvoteSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def get_queryset(self): + # Allow users to see only their own upvotes + return self.queryset.filter(user=self.request.user) + +class ForumDownvoteViewSet(viewsets.ModelViewSet): + queryset = ForumDownvote.objects.all() + serializer_class = ForumDownvoteSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def get_queryset(self): + # Allow users to see only their own downvotes + return self.queryset.filter(user=self.request.user) + \ No newline at end of file