From 937949a967f3144dd349776025f2e18c03b55497 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 22 May 2022 21:08:46 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Ajout=20des=20likes/dislikes=20dans=20les?= =?UTF-8?q?=20messages=20priv=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/mp/topic/index.html | 4 + zds/mp/api/serializers.py | 31 ++++ zds/mp/api/tests.py | 157 +++++++++++++++++- zds/mp/api/urls.py | 8 +- zds/mp/api/views.py | 9 +- .../0007_add_votes_to_private_post.py | 40 +++++ zds/mp/models.py | 63 +++++++ 7 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 zds/mp/migrations/0007_add_votes_to_private_post.py diff --git a/templates/mp/topic/index.html b/templates/mp/topic/index.html index 8e8a725d0c..cc7549d52e 100644 --- a/templates/mp/topic/index.html +++ b/templates/mp/topic/index.html @@ -68,6 +68,10 @@ {% endcaptureas %} {% endif %} + {% captureas karma_link %} + {% url "api:mp:mp-reaction-karma" topic.pk message.pk %} + {% endcaptureas %} + {% captureas unread_link %} {% url "private-post-unread" %}?message={{ message.pk }} {% endcaptureas %} diff --git a/zds/mp/api/serializers.py b/zds/mp/api/serializers.py index 408f9b5780..554338761b 100644 --- a/zds/mp/api/serializers.py +++ b/zds/mp/api/serializers.py @@ -2,6 +2,9 @@ from django.shortcuts import get_object_or_404 from dry_rest_permissions.generics import DRYPermissionsField from rest_framework import serializers +from rest_framework.fields import IntegerField +from rest_framework.serializers import ModelSerializer + from zds.api.serializers import ZdSModelSerializer from zds.member.api.serializers import UserListSerializer @@ -9,6 +12,7 @@ from zds.mp.models import PrivateTopic, PrivatePost from zds.mp.validators import ParticipantsUserValidator, TitleValidator, TextValidator from zds.mp.utils import send_mp, send_message_mp +from zds.utils.api.serializers import KarmaSerializer class PrivatePostSerializer(ZdSModelSerializer): @@ -189,3 +193,30 @@ def update(self, instance, validated_data): def throw_error(self, key=None, message=None): raise serializers.ValidationError(message) + + +class PrivatePostLikesSerializer(ModelSerializer): + count = IntegerField(source="like", read_only=True) + users = UserListSerializer(source="get_likers", many=True, read_only=True) + + class Meta: + model = PrivatePost + fields = ("count", "users") + + +class PrivatePostDislikesSerializer(ModelSerializer): + count = IntegerField(source="dislike", read_only=True) + users = UserListSerializer(source="get_dislikers", many=True, read_only=True) + + class Meta: + model = PrivatePost + fields = ("count", "users") + + +class PrivatePostKarmaSerializer(KarmaSerializer): + like = PrivatePostLikesSerializer(source="*", read_only=True) + dislike = PrivatePostDislikesSerializer(source="*", read_only=True) + + class Meta: + model = PrivatePost + fields = ("like", "dislike", "user", "vote") diff --git a/zds/mp/api/tests.py b/zds/mp/api/tests.py index 3dd6be9d48..159e36cb97 100644 --- a/zds/mp/api/tests.py +++ b/zds/mp/api/tests.py @@ -13,7 +13,7 @@ from zds.member.api.tests import create_oauth2_client, authenticate_client from zds.member.tests.factories import ProfileFactory, UserFactory from zds.mp.tests.factories import PrivateTopicFactory, PrivatePostFactory -from zds.mp.models import PrivateTopic +from zds.mp.models import PrivateTopic, PrivatePostVote class PrivateTopicListAPITest(APITestCase): @@ -1150,3 +1150,158 @@ def test_has_not_update_permission_for_authenticated_users_and_but_not_author_fo response = self.client.get(reverse("api:mp:message-detail", args=[self.private_topic.id, self.private_post.id])) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(response.data.get("permissions").get("update")) + + +class PrivateTopicKarmaAPITest(APITestCase): + def setUp(self): + self.profile = ProfileFactory() + self.private_topic = PrivateTopicFactory(author=self.profile.user) + self.private_post = PrivatePostFactory( + author=self.profile.user, privatetopic=self.private_topic, position_in_topic=1 + ) + self.client = APIClient() + client_oauth2 = create_oauth2_client(self.profile.user) + authenticate_client(self.client, client_oauth2, self.profile.user.username, "hostel77") + + caches[extensions_api_settings.DEFAULT_USE_CACHE].clear() + + def test_karma_of_private_post_not_in_participants(self): + """ + Gets an error 404 when the member doesn't have permission to display karma about the private post. + """ + another_profile = ProfileFactory() + another_private_topic = PrivateTopicFactory(author=another_profile.user) + another_private_post = PrivatePostFactory( + author=self.profile.user, privatetopic=another_private_topic, position_in_topic=1 + ) + + response = self.client.get( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, another_private_post.id]) + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_like_on_private_post(self): + """ + Add thumbs up to private post + """ + another_profile = ProfileFactory() + another_private_topic = PrivateTopicFactory(author=another_profile.user) + another_private_post = PrivatePostFactory( + author=another_profile.user, privatetopic=another_private_topic, position_in_topic=1 + ) + another_private_topic.participants.add(self.profile.user) + + response = self.client.put( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, another_private_post.id]), + {"vote": "like"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue( + PrivatePostVote.objects.filter( + user=self.profile.user, private_post=another_private_post, positive=True + ).exists() + ) + + def test_dislike_on_private_post(self): + """ + Add thumbs down to private post + """ + another_profile = ProfileFactory() + another_private_topic = PrivateTopicFactory(author=another_profile.user) + another_private_post = PrivatePostFactory( + author=another_profile.user, privatetopic=another_private_topic, position_in_topic=1 + ) + another_private_topic.participants.add(self.profile.user) + + response = self.client.put( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, another_private_post.id]), + {"vote": "dislike"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue( + PrivatePostVote.objects.filter( + user=self.profile.user, private_post=another_private_post, positive=False + ).exists() + ) + + def test_like_then_cancel_on_private_post(self): + """ + Add thumbs up to private post then cancels it + """ + another_profile = ProfileFactory() + another_private_topic = PrivateTopicFactory(author=another_profile.user) + another_private_post = PrivatePostFactory( + author=another_profile.user, privatetopic=another_private_topic, position_in_topic=1 + ) + another_private_topic.participants.add(self.profile.user) + + response = self.client.put( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, another_private_post.id]), + {"vote": "like"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.put( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, another_private_post.id]), + {"vote": "neutral"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse( + PrivatePostVote.objects.filter( + user=self.profile.user, private_post=another_private_post, positive=True + ).exists() + ) + + def test_get_private_post_voters(self): + """ + Verify that likes and dislikes voters can be retrieved + """ + another_profile1 = ProfileFactory() + another_profile2 = ProfileFactory() + another_private_topic = PrivateTopicFactory(author=another_profile1.user) + another_private_topic.participants.add(self.profile.user) + another_private_topic.participants.add(another_profile2.user) + + upvoted_post = PrivatePostFactory( + author=another_profile1.user, privatetopic=another_private_topic, position_in_topic=1, like=2 + ) + PrivatePostVote.objects.create(user=self.profile.user, private_post=upvoted_post, positive=True) + PrivatePostVote.objects.create(user=another_profile2.user, private_post=upvoted_post, positive=True) + + downvoted_post = PrivatePostFactory( + author=another_profile1.user, privatetopic=another_private_topic, position_in_topic=2, dislike=2 + ) + PrivatePostVote.objects.create(user=self.profile.user, private_post=downvoted_post, positive=False) + PrivatePostVote.objects.create(user=another_profile2.user, private_post=downvoted_post, positive=False) + + neutral_post = PrivatePostFactory( + author=another_profile1.user, privatetopic=another_private_topic, position_in_topic=3, like=1, dislike=1 + ) + PrivatePostVote.objects.create(user=self.profile.user, private_post=neutral_post, positive=True) + PrivatePostVote.objects.create(user=another_profile2.user, private_post=neutral_post, positive=False) + + response = self.client.get( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, upvoted_post.id]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(2, len(response.data["like"]["users"])) + self.assertEqual(0, len(response.data["dislike"]["users"])) + self.assertEqual(2, response.data["like"]["count"]) + self.assertEqual(0, response.data["dislike"]["count"]) + + response = self.client.get( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, downvoted_post.id]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(0, len(response.data["like"]["users"])) + self.assertEqual(2, len(response.data["dislike"]["users"])) + self.assertEqual(0, response.data["like"]["count"]) + self.assertEqual(2, response.data["dislike"]["count"]) + + response = self.client.get( + reverse("api:mp:mp-reaction-karma", args=[another_private_topic.id, neutral_post.id]) + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(1, len(response.data["like"]["users"])) + self.assertEqual(1, len(response.data["dislike"]["users"])) + self.assertEqual(1, response.data["like"]["count"]) + self.assertEqual(1, response.data["dislike"]["count"]) diff --git a/zds/mp/api/urls.py b/zds/mp/api/urls.py index a48d8bbe22..b545efb727 100644 --- a/zds/mp/api/urls.py +++ b/zds/mp/api/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import re_path, path from zds.mp.api.views import ( PrivateTopicListAPI, @@ -6,6 +6,7 @@ PrivatePostListAPI, PrivatePostDetailAPI, PrivateTopicReadAPI, + PrivatePostReactionKarmaView, ) urlpatterns = [ @@ -15,5 +16,10 @@ re_path( r"^(?P[0-9]+)/messages/(?P[0-9]+)/?$", PrivatePostDetailAPI.as_view(), name="message-detail" ), + re_path( + r"^(?P[0-9]+)/messages/(?P[0-9]+)/karma/$", + PrivatePostReactionKarmaView.as_view(), + name="mp-reaction-karma", + ), re_path(r"^unread/$", PrivateTopicReadAPI.as_view(), name="list-unread"), ] diff --git a/zds/mp/api/views.py b/zds/mp/api/views.py index 9bc7583cf9..33487fad07 100644 --- a/zds/mp/api/views.py +++ b/zds/mp/api/views.py @@ -14,7 +14,7 @@ RetrieveUpdateAPIView, ListAPIView, ) -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly from rest_framework.response import Response from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.etag.decorators import etag @@ -36,6 +36,7 @@ PrivateTopicCreateSerializer, PrivatePostSerializer, PrivatePostActionSerializer, + PrivatePostKarmaSerializer, ) from zds.mp.commons import LeavePrivateTopic from zds.mp.models import PrivateTopic, PrivatePost, mark_read @@ -579,3 +580,9 @@ def get_queryset(self): subscription__content_type=ContentType.objects.get_for_model(PrivateTopic) ) return [notification.content_object.privatetopic for notification in notifications] + + +class PrivatePostReactionKarmaView(RetrieveUpdateDestroyAPIView): + queryset = PrivatePost.objects.all() + serializer_class = PrivatePostKarmaSerializer + permission_classes = (IsAuthenticated, IsParticipantFromPrivatePost, DRYPermissions) diff --git a/zds/mp/migrations/0007_add_votes_to_private_post.py b/zds/mp/migrations/0007_add_votes_to_private_post.py new file mode 100644 index 0000000000..b9f4263328 --- /dev/null +++ b/zds/mp/migrations/0007_add_votes_to_private_post.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.12 on 2022-05-22 21:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("mp", "0006_auto_20190114_1301"), + ] + + operations = [ + migrations.AddField( + model_name="privatepost", + name="dislike", + field=models.IntegerField(default=0, verbose_name="Dislikes"), + ), + migrations.AddField( + model_name="privatepost", + name="like", + field=models.IntegerField(default=0, verbose_name="Likes"), + ), + migrations.CreateModel( + name="PrivatePostVote", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("positive", models.BooleanField(default=True, verbose_name="Est un vote positif")), + ("private_post", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="mp.privatepost")), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "Vote", + "verbose_name_plural": "Votes", + "unique_together": {("user", "private_post")}, + }, + ), + ] diff --git a/zds/mp/models.py b/zds/mp/models.py index 0c6b06d3a2..089691f854 100644 --- a/zds/mp/models.py +++ b/zds/mp/models.py @@ -353,6 +353,9 @@ class Meta: blank=True, null=True, ) + like = models.IntegerField("Likes", default=0) + dislike = models.IntegerField("Dislikes", default=0) + objects = PrivatePostManager() def __str__(self): @@ -397,6 +400,49 @@ def is_last_message(self, private_topic=None): is_same_private_topic = private_topic == self.privatetopic return is_same_private_topic and self.privatetopic.last_message == self + def get_user_vote(self, user): + """Get a user vote (like, dislike or neutral)""" + if user.is_authenticated: + try: + user_vote = "like" if PrivatePostVote.objects.get(user=user, private_post=self).positive else "dislike" + except PrivatePostVote.DoesNotExist: + user_vote = "neutral" + else: + user_vote = "neutral" + + return user_vote + + def set_user_vote(self, user, vote): + """Set a user vote (like, dislike or neutral)""" + if vote == "neutral": + PrivatePostVote.objects.filter(user=user, private_post=self).delete() + else: + PrivatePostVote.objects.update_or_create( + user=user, private_post=self, defaults={"positive": (vote == "like")} + ) + + self.like = PrivatePostVote.objects.filter(positive=True, private_post=self).count() + self.dislike = PrivatePostVote.objects.filter(positive=False, private_post=self).count() + + def get_votes(self): + """Get the non-anonymous votes""" + if not hasattr(self, "votes"): + self.votes = ( + PrivatePostVote.objects.filter(private_post=self, id__gt=settings.VOTES_ID_LIMIT) + .select_related("user") + .all() + ) + + return self.votes + + def get_likers(self): + """Get the list of the users that liked this PrivatePost""" + return [vote.user for vote in self.get_votes() if vote.positive] + + def get_dislikers(self): + """Get the list of the users that disliked this PrivatePost""" + return [vote.user for vote in self.get_votes() if not vote.positive] + @staticmethod def has_read_permission(request): return request.user.is_authenticated @@ -415,6 +461,23 @@ def has_object_update_permission(self, request): return PrivateTopic.has_write_permission(request) and self.is_last_message() and self.is_author(request.user) +class PrivatePostVote(models.Model): + + """Set of Private Post votes.""" + + class Meta: + verbose_name = "Vote" + verbose_name_plural = "Votes" + unique_together = ("user", "private_post") + + private_post = models.ForeignKey(PrivatePost, db_index=True, on_delete=models.CASCADE) + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + positive = models.BooleanField("Est un vote positif", default=True) + + def __str__(self): + return f"Vote from {self.user.username} about PrivatePost#{self.private_post.pk} thumb_up={self.positive}" + + class PrivateTopicRead(models.Model): """ Small model which keeps track of the user viewing private topics. From 93c98b1dfc646c52add5e21912bbcc0125676852 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 25 May 2022 20:52:32 +0200 Subject: [PATCH 2/2] Isolation des tests --- zds/forum/api/tests.py | 3 +++ zds/tutorialv2/api/tests.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/zds/forum/api/tests.py b/zds/forum/api/tests.py index cf65fc264f..8e7793f41d 100644 --- a/zds/forum/api/tests.py +++ b/zds/forum/api/tests.py @@ -168,6 +168,7 @@ def test_get_post_voters(self): self.assertEqual(1, response.data["dislike"]["count"]) # Now we change the settings to keep anonymous the first [dis]like + previous_limit = settings.VOTES_ID_LIMIT settings.VOTES_ID_LIMIT = anon_limit.pk # and we run the same tests # on first message we should see 1 like and 1 anonymous @@ -193,3 +194,5 @@ def test_get_post_voters(self): self.assertEqual(1, len(response.data["dislike"]["users"])) self.assertEqual(1, response.data["like"]["count"]) self.assertEqual(1, response.data["dislike"]["count"]) + + settings.VOTES_ID_LIMIT = previous_limit diff --git a/zds/tutorialv2/api/tests.py b/zds/tutorialv2/api/tests.py index 7312856224..26f72b01ee 100644 --- a/zds/tutorialv2/api/tests.py +++ b/zds/tutorialv2/api/tests.py @@ -165,6 +165,7 @@ def test_get_content_reaction_voters(self): self.assertEqual(1, response.data["dislike"]["count"]) # Now we change the settings to keep anonymous the first [dis]like + previous_limit = settings.VOTES_ID_LIMIT settings.VOTES_ID_LIMIT = anon_limit.pk # and we run the same tests # on first message we should see 1 like and 1 anonymous @@ -190,6 +191,7 @@ def test_get_content_reaction_voters(self): self.assertEqual(1, len(response.data["dislike"]["users"])) self.assertEqual(1, response.data["like"]["count"]) self.assertEqual(1, response.data["dislike"]["count"]) + settings.VOTES_ID_LIMIT = previous_limit @override_for_contents()