Browse Source

like/unlike post api

Rafał Pitoń 8 years ago
parent
commit
78e07c5505

+ 57 - 0
misago/threads/api/postendpoints/patch_post.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext as _
 
 from misago.acl import add_acl
 from misago.core.apipatch import ApiPatch
+from ...models import PostLike
 from ...moderation import posts as moderation
 from ...permissions.threads import allow_approve_post, allow_hide_post, allow_protect_post, allow_unhide_post
 
@@ -20,6 +21,62 @@ def patch_acl(request, post, value):
 post_patch_dispatcher.add('acl', patch_acl)
 
 
+def patch_is_liked(request, post, value):
+    if not post.acl['can_like']:
+        raise PermissionDenied(_("You can't like posts in this category."))
+
+    # grab like state for this post and user
+    try:
+        user_like = post.postlike_set.get(user=request.user)
+    except PostLike.DoesNotExist:
+        user_like = None
+
+    # no change
+    if (value and user_like) or (not value and not user_like):
+        return {
+            'likes': post.likes,
+            'last_likes': post.last_likes or [],
+            'is_liked': value,
+        }
+
+    # like
+    if value:
+        post.postlike_set.create(
+            category=post.category,
+            thread=post.thread,
+            user=request.user,
+            user_name=request.user.username,
+            user_slug=request.user.slug,
+            user_ip=request.user_ip
+        )
+        post.likes += 1
+
+    # unlike
+    if not value:
+        user_like.delete()
+        post.likes -= 1
+
+    post.last_likes = []
+    for like in post.postlike_set.all()[:3]:
+        post.last_likes.append({
+            'username': request.user.username,
+            'slug': request.user.slug,
+            'url': None
+        })
+
+        if like.user_id:
+            post.last_likes[-1]['url'] = request.user.get_absolute_url()
+
+    post.save(update_fields=['likes', 'last_likes'])
+
+    return {
+        'likes': post.likes,
+        'last_likes': post.last_likes or [],
+        'is_liked': value,
+    }
+post_patch_dispatcher.replace('is-liked', patch_is_liked)
+
+
 def patch_is_protected(request, post, value):
     allow_protect_post(request.user, post)
     if value:

+ 36 - 0
misago/threads/migrations/0001_initial.py

@@ -49,6 +49,8 @@ class Migration(migrations.Migration):
                 ('is_event', models.BooleanField(default=False, db_index=True)),
                 ('event_type', models.CharField(max_length=255, null=True, blank=True)),
                 ('event_context', JSONField(null=True, blank=True)),
+                ('likes', models.PositiveIntegerField(default=0)),
+                ('last_likes', JSONField(blank=True, null=True)),
             ],
             options={
             },
@@ -320,4 +322,38 @@ class Migration(migrations.Migration):
                 ('poll', 'voter_name'),
             ]),
         ),
+        migrations.CreateModel(
+            name='PostLike',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('user_name', models.CharField(max_length=255, db_index=True)),
+                ('user_slug', models.CharField(max_length=255)),
+                ('user_ip', models.GenericIPAddressField()),
+                ('liked_on', models.DateTimeField(default=django.utils.timezone.now)),
+                ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_categories.Category')),
+            ],
+            options={
+                'ordering': ['-id'],
+            },
+        ),
+        migrations.AddField(
+            model_name='postlike',
+            name='post',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Post'),
+        ),
+        migrations.AddField(
+            model_name='postlike',
+            name='thread',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'),
+        ),
+        migrations.AddField(
+            model_name='postlike',
+            name='user',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name='post',
+            name='liked_by',
+            field=models.ManyToManyField(related_name='liked_post_set', through='misago_threads.PostLike', to=settings.AUTH_USER_MODEL),
+        ),
     ]

+ 1 - 0
misago/threads/models/__init__.py

@@ -1,6 +1,7 @@
 # flake8: noqa
 from .post import Post
 from .postedit import PostEdit
+from .postlike import PostLike
 from .thread import *
 from .threadparticipant import ThreadParticipant
 from .subscription import Subscription

+ 9 - 0
misago/threads/models/post.py

@@ -67,6 +67,15 @@ class Post(models.Model):
     event_type = models.CharField(max_length=255, null=True, blank=True)
     event_context = JSONField(null=True, blank=True)
 
+    likes = models.PositiveIntegerField(default=0)
+    last_likes = JSONField(null=True, blank=True)
+
+    liked_by = models.ManyToManyField(
+        settings.AUTH_USER_MODEL,
+        related_name='liked_post_set',
+        through='misago_threads.PostLike',
+    )
+
     class Meta:
         index_together = [
             ('is_event', 'is_hidden'),

+ 24 - 0
misago/threads/models/postlike.py

@@ -0,0 +1,24 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+
+class PostLike(models.Model):
+    category = models.ForeignKey('misago_categories.Category')
+    thread = models.ForeignKey('misago_threads.Thread')
+    post = models.ForeignKey('misago_threads.Post')
+
+    user = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+    )
+    user_name = models.CharField(max_length=255, db_index=True)
+    user_slug = models.CharField(max_length=255)
+    user_ip = models.GenericIPAddressField()
+
+    liked_on = models.DateTimeField(default=timezone.now)
+
+    class Meta:
+        ordering = ['-id']

+ 7 - 2
misago/threads/permissions/threads.py

@@ -151,7 +151,10 @@ class CategoryPermissionsForm(forms.Form):
             (2, _("Number and list of likers"))
         )
     )
-    can_like_posts = forms.YesNoSwitch(label=_("Can like posts"))
+    can_like_posts = forms.YesNoSwitch(
+        label=_("Can like posts"),
+        help_text=_("Only users with this permission to see likes can like posts.")
+    )
 
     can_protect_posts = forms.YesNoSwitch(
         label=_("Can protect posts"),
@@ -428,11 +431,13 @@ def add_acl_to_reply(user, post):
         'can_report': category_acl.get('can_report_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
         'can_see_likes': category_acl.get('can_see_posts_likes', 0),
-        'can_like_posts': category_acl.get('can_like_posts', False),
+        'can_like': False,
     })
 
     if not post.acl['can_see_hidden']:
         post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
+    if user.is_authenticated() and post.acl['can_see_likes']:
+        post.acl['can_like'] = category_acl.get('can_like_posts', False)
 
 
 def register_with(registry):

+ 145 - 27
misago/threads/tests/test_thread_postpatch_api.py

@@ -6,14 +6,13 @@ from datetime import timedelta
 
 from django.core.urlresolvers import reverse
 from django.utils import timezone
-from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.users.testutils import AuthenticatedUserTestCase
 
 from .. import testutils
-from ..models import Thread
+from ..models import Post, Thread
 
 
 class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
@@ -59,7 +58,7 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertTrue(response_json['acl'])
 
     def test_add_acl_false(self):
@@ -69,7 +68,7 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertIsNone(response_json['acl'])
 
         response = self.patch(self.api_link, [
@@ -121,7 +120,7 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
 
         self.refresh_post()
@@ -141,7 +140,7 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
 
         self.refresh_post()
@@ -159,7 +158,7 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
 
         self.refresh_post()
@@ -212,7 +211,7 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve posts in this category.")
 
         self.refresh_post()
@@ -235,7 +234,7 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve thread's first post.")
 
         self.refresh_post()
@@ -256,7 +255,7 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve posts the content you can't see.")
 
         self.refresh_post()
@@ -343,7 +342,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide posts in this category.")
 
         self.refresh_post()
@@ -366,7 +365,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't reveal posts in this category.")
 
         self.refresh_post()
@@ -387,7 +386,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This post is protected. You can't hide it.")
 
         self.refresh_post()
@@ -411,7 +410,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This post is protected. You can't reveal it.")
 
         self.refresh_post()
@@ -431,7 +430,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide other users posts in this category.")
 
         self.refresh_post()
@@ -452,7 +451,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't reveal other users posts in this category.")
 
         self.refresh_post()
@@ -473,7 +472,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide posts that are older than 1 minute.")
 
         self.refresh_post()
@@ -495,7 +494,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't reveal posts that are older than 1 minute.")
 
         self.refresh_post()
@@ -515,7 +514,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This thread is closed. You can't hide posts in it.")
 
         self.refresh_post()
@@ -538,7 +537,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This thread is closed. You can't reveal posts in it.")
 
         self.refresh_post()
@@ -558,7 +557,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This category is closed. You can't hide posts in it.")
 
         self.refresh_post()
@@ -581,7 +580,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This category is closed. You can't reveal posts in it.")
 
         self.refresh_post()
@@ -601,7 +600,7 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide thread's first post.")
 
     def test_show_first_post(self):
@@ -618,10 +617,129 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't reveal thread's first post.")
 
 
+class PostLikeApiTests(ThreadPostPatchApiTestCase):
+    def like_post(self):
+        self.post.postlike_set.create(
+            category=self.category,
+            thread=self.thread,
+            user=self.user,
+            user_name=self.user.username,
+            user_slug=self.user.slug,
+            user_ip='127.0.0.1'
+        )
+        self.post.likes += 1
+        self.post.last_likes = [
+            {
+                'username': self.user.username,
+                'slug': self.user.slug,
+                'url': self.user.get_absolute_url()
+            }
+        ]
+        self.post.save()
+
+    def test_like_no_see_permission(self):
+        """api validates user's permission to see posts likes"""
+        self.override_acl({
+            'can_see_posts_likes': 0
+        })
+
+        response = self.patch(self.api_link, [
+            {'op': 'replace', 'path': 'is-liked', 'value': True}
+        ])
+        self.assertContains(response, "You can't like posts in this category.", status_code=400)
+
+    def test_like_no_like_permission(self):
+        """api validates user's permission to see posts likes"""
+        self.override_acl({
+            'can_like_posts': False
+        })
+
+        response = self.patch(self.api_link, [
+            {'op': 'replace', 'path': 'is-liked', 'value': True}
+        ])
+        self.assertContains(response, "You can't like posts in this category.", status_code=400)
+
+    def test_like_post(self):
+        """api adds user like to post"""
+        response = self.patch(self.api_link, [
+            {'op': 'replace', 'path': 'is-liked', 'value': True}
+        ])
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+        self.assertEqual(response_json['likes'], 1)
+        self.assertEqual(response_json['is_liked'], True)
+        self.assertEqual(response_json['last_likes'], [
+            {
+                'username': self.user.username,
+                'slug': self.user.slug,
+                'url': self.user.get_absolute_url()
+            }
+        ])
+
+        post = Post.objects.get(pk=self.post.pk)
+        self.assertEqual(post.likes, response_json['likes'])
+        self.assertEqual(post.last_likes, response_json['last_likes'])
+
+    def test_unlike_post(self):
+        """api removes user like from post"""
+        self.like_post()
+
+        response = self.patch(self.api_link, [
+            {'op': 'replace', 'path': 'is-liked', 'value': False}
+        ])
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+        self.assertEqual(response_json['likes'], 0)
+        self.assertEqual(response_json['is_liked'], False)
+        self.assertEqual(response_json['last_likes'], [])
+
+        post = Post.objects.get(pk=self.post.pk)
+        self.assertEqual(post.likes, response_json['likes'])
+        self.assertEqual(post.last_likes, response_json['last_likes'])
+
+    def test_like_post_no_change(self):
+        """api does no state change if we are linking liked post"""
+        self.like_post()
+
+        response = self.patch(self.api_link, [
+            {'op': 'replace', 'path': 'is-liked', 'value': True}
+        ])
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+        self.assertEqual(response_json['likes'], 1)
+        self.assertEqual(response_json['is_liked'], True)
+        self.assertEqual(response_json['last_likes'], [
+            {
+                'username': self.user.username,
+                'slug': self.user.slug,
+                'url': self.user.get_absolute_url()
+            }
+        ])
+
+        post = Post.objects.get(pk=self.post.pk)
+        self.assertEqual(post.likes, response_json['likes'])
+        self.assertEqual(post.last_likes, response_json['last_likes'])
+
+    def test_unlike_post_no_change(self):
+        """api does no state change if we are unlinking unliked post"""
+        response = self.patch(self.api_link, [
+            {'op': 'replace', 'path': 'is-liked', 'value': False}
+        ])
+        self.assertEqual(response.status_code, 200)
+
+        response_json = response.json()
+        self.assertEqual(response_json['likes'], 0)
+        self.assertEqual(response_json['is_liked'], False)
+        self.assertEqual(response_json['last_likes'], [])
+
+
 class ThreadEventPatchApiTestCase(ThreadPostPatchApiTestCase):
     def setUp(self):
         super(ThreadEventPatchApiTestCase, self).setUp()
@@ -656,7 +774,7 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertTrue(response_json['acl'])
 
     def test_add_acl_false(self):
@@ -666,7 +784,7 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertIsNone(response_json['acl'])
 
         response = self.patch(self.api_link, [
@@ -721,7 +839,7 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         ])
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You don't have permission to hide this event.")
 
         self.refresh_event()