Browse Source

merge posts api endpoint

Rafał Pitoń 8 years ago
parent
commit
eae41bcd49

+ 95 - 0
misago/threads/api/postendpoints/merge.py

@@ -0,0 +1,95 @@
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext as _, ungettext
+
+from rest_framework.response import Response
+
+from misago.acl import add_acl
+
+from ...permissions.threads import exclude_invisible_posts
+from ...serializers import PostSerializer
+
+
+MERGE_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
+
+
+class MergeError(Exception):
+    def __init__(self, msg):
+        self.msg = msg
+
+
+def posts_merge_endpoint(request, thread):
+    if not thread.acl['can_merge_posts']:
+        raise PermissionDenied(_("You can't merge posts in this thread."))
+
+    try:
+        posts = clean_posts_for_merge(request, thread)
+    except MergeError as e:
+        return Response({'detail': e.msg}, status=400)
+
+    first_post, merged_posts = posts[0], posts[1:]
+    for post in merged_posts:
+        post.merge(first_post)
+        post.delete()
+    first_post.save()
+
+    thread.synchronize()
+    thread.save()
+
+    thread.category.synchronize()
+    thread.category.save()
+
+    first_post.thread = thread
+    first_post.category = thread.category
+
+    add_acl(request.user, first_post)
+
+    return Response(PostSerializer(first_post).data)
+
+
+def clean_posts_for_merge(request, thread):
+    try:
+        posts_ids = list(map(int, request.data.get('posts', [])))
+    except (ValueError, TypeError):
+        raise MergeError(_("One or more post ids received were invalid."))
+
+    if len(posts_ids) < 2:
+        raise MergeError(_("You have to select at least two posts to merge."))
+    elif len(posts_ids) > MERGE_LIMIT:
+        message = ungettext(
+            "No more than %(limit)s post can be merged at single time.",
+            "No more than %(limit)s posts can be merged at single time.",
+            MERGE_LIMIT)
+        raise MergeError(message % {'limit': MERGE_LIMIT})
+
+    posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
+    posts_queryset = posts_queryset.select_for_update().filter(id__in=posts_ids).order_by('id')
+
+    posts = []
+    for post in posts_queryset:
+        if post.is_event:
+            raise MergeError(_("Events can't be merged."))
+        if post.is_hidden and not (post.pk == thread.first_post_id or thread.category.acl['can_hide_posts']):
+            raise MergeError(_("You can't merge posts the content you can't see"))
+
+        if not posts:
+            posts.append(post)
+        else:
+            authorship_error = _("Posts made by different users can't be merged.")
+            if posts[0].poster_id:
+                if post.poster_id != posts[0].poster_id:
+                    raise MergeError(authorship_error)
+            else:
+                if post.poster_id or post.poster_name != posts[0].poster_name:
+                    raise MergeError(authorship_error)
+
+            if posts[0].pk != thread.first_post_id:
+                if posts[0].is_hidden != post.is_hidden or posts[0].is_unapproved != post.is_unapproved:
+                    raise MergeError(_("Posts with different visibility can't be merged."))
+
+            posts.append(post)
+
+    if len(posts) != len(posts_ids):
+        raise MergeError(_("One or more posts to merge could not be found."))
+
+    return posts

+ 0 - 0
misago/threads/api/postendpoints/move.py


+ 0 - 0
misago/threads/api/postendpoints/split.py


+ 2 - 3
misago/threads/api/threadendpoints/merge.py

@@ -1,7 +1,6 @@
 from django.core.exceptions import PermissionDenied
 from django.http import Http404
-from django.utils.translation import gettext as _
-from django.utils.translation import ungettext
+from django.utils.translation import gettext as _, ungettext
 
 from rest_framework.response import Response
 
@@ -101,7 +100,7 @@ def threads_merge_endpoint(request):
 
 def clean_threads_for_merge(request):
     try:
-        threads_ids = list(map(int, request.data.get('threads', [])))
+        threads_ids = list(map(int, request.data.getlist('threads', [])))
     except (ValueError, TypeError):
         raise MergeError(_("One or more thread ids received were invalid."))
 

+ 7 - 0
misago/threads/api/threadposts.py

@@ -18,6 +18,7 @@ from ..viewmodels.post import ThreadPost
 from ..viewmodels.posts import ThreadPosts
 from ..viewmodels.thread import ForumThread
 from .postingendpoint import PostingEndpoint
+from .postendpoints.merge import posts_merge_endpoint
 from .postendpoints.patch_event import event_patch_endpoint
 from .postendpoints.patch_post import post_patch_endpoint
 
@@ -67,6 +68,12 @@ class ViewSet(viewsets.ViewSet):
 
         return Response(data)
 
+    @list_route(methods=['post'], url_path='merge')
+    @transaction.atomic
+    def merge(self, request, thread_pk):
+        thread = self.get_thread_for_update(request, thread_pk).thread
+        return posts_merge_endpoint(request, thread)
+
     @transaction.atomic
     def create(self, request, thread_pk):
         thread = self.get_thread_for_update(request, thread_pk).thread

+ 3 - 8
misago/threads/models/post.py

@@ -80,15 +80,10 @@ class Post(models.Model):
 
     def merge(self, other_post):
         if self.thread_id != other_post.thread_id:
-            message = "only posts belonging to same thread can be merged"
-            raise ValueError(message)
+            raise ValueError("only posts belonging to same thread can be merged")
 
-        message = "posts made by different authors can't be merged"
-        if self.poster_id and other_post.poster_id:
-            if self.poster_id != other_post.poster_id:
-                raise ValueError(message)
-        else:
-            raise ValueError(message)
+        if self.is_event or other_post.is_event:
+            raise ValueError("can't merge events")
 
         if self.pk == other_post.pk:
             raise ValueError("post can't be merged with itself")

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

@@ -400,6 +400,9 @@ def add_acl_to_thread(user, thread):
         'can_close': category_acl.get('can_close_threads', False),
         'can_move': False,
         'can_merge': False,
+        'can_move_posts': False,
+        'can_merge_posts': False,
+        'can_split': False,
         'can_approve': category_acl.get('can_approve_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
     })
@@ -419,6 +422,10 @@ def add_acl_to_thread(user, thread):
         thread.acl['can_move'] = category_acl.get('can_move_threads', False)
         thread.acl['can_merge'] = category_acl.get('can_merge_threads', False)
 
+        thread.acl['can_move_posts'] = category_acl.get('can_move_posts', False)
+        thread.acl['can_merge_posts'] = category_acl.get('can_merge_posts', False)
+        thread.acl['can_split'] = category_acl.get('can_split_threads', False)
+
 
 def add_acl_to_post(user, post):
     if post.is_event:

+ 288 - 0
misago/threads/tests/test_thread_postmerge_api.py

@@ -0,0 +1,288 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import json
+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 ..api.postendpoints.merge import MERGE_LIMIT
+from ..models import Post, Thread
+
+
+class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(ThreadPostMergeApiTestCase, self).setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.thread = testutils.post_thread(category=self.category)
+        self.post = testutils.reply_thread(self.thread, poster=self.user)
+
+        self.api_link = reverse('misago:api:thread-post-merge', kwargs={
+            'thread_pk': self.thread.pk
+        })
+
+        self.override_acl()
+
+    def refresh_thread(self):
+        self.thread = Thread.objects.get(pk=self.thread.pk)
+
+    def override_acl(self, extra_acl=None):
+        new_acl = self.user.acl
+        new_acl['categories'][self.category.pk].update({
+            'can_see': 1,
+            'can_browse': 1,
+            'can_start_threads': 0,
+            'can_reply_threads': 0,
+            'can_edit_posts': 1,
+            'can_approve_content': 0,
+            'can_merge_posts': 1
+        })
+
+        if extra_acl:
+            new_acl['categories'][self.category.pk].update(extra_acl)
+
+        override_acl(self.user, new_acl)
+
+    def test_anonymous_user(self):
+        """you need to authenticate to merge posts"""
+        self.logout_user()
+
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+    def test_no_permission(self):
+        """api validates permission to merge"""
+        self.override_acl({
+            'can_merge_posts': 0
+        })
+
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertContains(response, "You can't merge posts in this thread.", status_code=403)
+
+    def test_closed_thread(self):
+        """api validates permission to merge in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
+
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertContains(response, "You can't merge posts in this thread.", status_code=403)
+
+        # allow closing threads
+        self.override_acl({
+            'can_close_threads': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+
+    def test_closed_category(self):
+        """api validates permission to merge in closed category"""
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertContains(response, "You can't merge posts in this thread.", status_code=403)
+
+        # allow closing threads
+        self.override_acl({
+            'can_close_threads': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+
+    def test_empty_data(self):
+        """api handles empty data"""
+        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+
+    def test_no_posts_ids(self):
+        """api rejects no posts ids"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': []
+        }), content_type="application/json")
+        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+
+    def test_invalid_posts_data(self):
+        """api handles invalid data"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': 'string'
+        }), content_type="application/json")
+        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+
+    def test_invalid_posts_ids(self):
+        """api handles invalid post id"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [1, 2, 'string']
+        }), content_type="application/json")
+        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+
+    def test_one_post_id(self):
+        """api rejects one post id"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [1]
+        }), content_type="application/json")
+        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+
+    def test_merge_limit(self):
+        """api rejects more posts than merge limit"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': list(xrange(MERGE_LIMIT + 1))
+        }), content_type="application/json")
+        self.assertContains(response, "No more than {} posts can be merged".format(MERGE_LIMIT), status_code=400)
+
+    def test_merge_event(self):
+        """api recjects events"""
+        event = testutils.reply_thread(self.thread, is_event=True, poster=self.user)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [self.post.pk, event.pk]
+        }), content_type="application/json")
+        self.assertContains(response, "Events can't be merged.", status_code=400)
+
+    def test_merge_notfound_pk(self):
+        """api recjects nonexistant pk's"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [self.post.pk, self.post.pk * 1000]
+        }), content_type="application/json")
+        self.assertContains(response, "One or more posts to merge could not be found.", status_code=400)
+
+    def test_merge_cross_threads(self):
+        """api recjects attempt to merge with post made in other thread"""
+        other_thread = testutils.post_thread(category=self.category)
+        other_post = testutils.reply_thread(other_thread, poster=self.user)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [self.post.pk, other_post.pk]
+        }), content_type="application/json")
+        self.assertContains(response, "One or more posts to merge could not be found.", status_code=400)
+
+    def test_merge_authenticated_with_guest_post(self):
+        """api recjects attempt to merge with post made by deleted user"""
+        other_post = testutils.reply_thread(self.thread)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [self.post.pk, other_post.pk]
+        }), content_type="application/json")
+        self.assertContains(response, "Posts made by different users can't be merged.", status_code=400)
+
+    def test_merge_guest_with_authenticated_post(self):
+        """api recjects attempt to merge with post made by deleted user"""
+        other_post = testutils.reply_thread(self.thread)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [other_post.pk, self.post.pk]
+        }), content_type="application/json")
+        self.assertContains(response, "Posts made by different users can't be merged.", status_code=400)
+
+    def test_merge_guest_posts_different_usernames(self):
+        """api recjects attempt to merge posts made by different guests"""
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [
+                testutils.reply_thread(self.thread, poster="Bob").pk,
+                testutils.reply_thread(self.thread, poster="Miku").pk
+            ]
+        }), content_type="application/json")
+        self.assertContains(response, "Posts made by different users can't be merged.", status_code=400)
+
+    def test_merge_different_visibility(self):
+        """api recjects attempt to merge posts with different visibility"""
+        self.override_acl({
+            'can_hide_posts': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [
+                testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk,
+                testutils.reply_thread(self.thread, poster="Bob", is_hidden=False).pk
+            ]
+        }), content_type="application/json")
+        self.assertContains(response, "Posts with different visibility can't be merged.", status_code=400)
+
+    def test_merge_different_approval(self):
+        """api recjects attempt to merge posts with different approval"""
+        self.override_acl({
+            'can_approve_content': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [
+                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk,
+                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=False).pk
+            ]
+        }), content_type="application/json")
+        self.assertContains(response, "Posts with different visibility can't be merged.", status_code=400)
+
+    def test_merge_posts(self):
+        """api merges two posts"""
+        post_a = testutils.reply_thread(self.thread, poster="Bob", message="Battery")
+        post_b = testutils.reply_thread(self.thread, poster="Bob", message="Horse")
+
+        thread_replies = self.thread.replies
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [post_a.pk, post_b.pk]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        self.refresh_thread()
+        self.assertEqual(self.thread.replies, thread_replies - 1)
+
+        with self.assertRaises(Post.DoesNotExist):
+            Post.objects.get(pk=post_b.pk)
+
+        merged_post = Post.objects.get(pk=post_a.pk)
+        self.assertEqual(merged_post.parsed, '{}\n{}'.format(post_a.parsed, post_b.parsed))
+
+    def test_merge_hidden_posts(self):
+        """api merges two hidden posts"""
+        self.override_acl({
+            'can_hide_posts': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [
+                testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk,
+                testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk
+            ]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+    def test_merge_unapproved_posts(self):
+        """api merges two unapproved posts"""
+        self.override_acl({
+            'can_approve_content': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [
+                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk,
+                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk
+            ]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+    def test_merge_with_hidden_thread(self):
+        """api recjects attempt to merge posts with different visibility"""
+        self.thread.first_post.is_hidden = True
+        self.thread.first_post.poster = self.user
+        self.thread.first_post.save()
+
+        post_visible = testutils.reply_thread(self.thread, poster=self.user, is_hidden=False)
+
+        self.override_acl({
+            'can_hide_threads': 1
+        })
+
+        response = self.client.post(self.api_link, json.dumps({
+            'posts': [self.thread.first_post.pk, post_visible.pk]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 200)

+ 0 - 1
misago/threads/tests/test_threadpost_patch_api.py → misago/threads/tests/test_thread_postpatch_api.py

@@ -5,7 +5,6 @@ import json
 from datetime import timedelta
 
 from django.core.urlresolvers import reverse
-from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
 from django.utils import timezone
 from django.utils.encoding import smart_str