Browse Source

merge threads backend and small cleanup

Rafał Pitoń 8 years ago
parent
commit
6edb7b1d97

+ 52 - 0
misago/threads/api/threadendpoints/editor.py

@@ -0,0 +1,52 @@
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import gettext as _
+
+from rest_framework.response import Response
+
+from misago.acl import add_acl
+from misago.categories.models import Category
+
+from ...permissions.threads import can_start_thread
+
+
+def thread_start_editor(request):
+    if request.user.is_anonymous():
+        raise PermissionDenied(_("You need to be signed in to start threads."))
+
+    # list of categories that allow or contain subcategories that allow new threads
+    available = []
+
+    categories = []
+    for category in Category.objects.filter(pk__in=request.user.acl['browseable_categories']).order_by('-lft'):
+        add_acl(request.user, category)
+
+        post = False
+        if can_start_thread(request.user, category):
+            post = {
+                'close': bool(category.acl['can_close_threads']),
+                'hide': bool(category.acl['can_hide_threads']),
+                'pin': category.acl['can_pin_threads']
+            }
+
+            available.append(category.pk)
+            available.append(category.parent_id)
+        elif category.pk in available:
+            available.append(category.parent_id)
+
+        categories.append({
+            'id': category.pk,
+            'name': category.name,
+            'level': category.level - 1,
+            'post': post
+        })
+
+    # list only categories that allow new threads, or contains subcategory that allows one
+    cleaned_categories = []
+    for category in reversed(categories):
+        if category['id'] in available:
+            cleaned_categories.append(category)
+
+    if not cleaned_categories:
+        raise PermissionDenied(_("No categories that allow new threads are available to you at the moment."))
+
+    return Response(cleaned_categories)

+ 46 - 1
misago/threads/api/threadendpoints/merge.py

@@ -1,5 +1,6 @@
 from django.core.exceptions import PermissionDenied
 from django.db import transaction
+from django.http import Http404
 from django.utils.translation import gettext as _
 from django.utils.translation import ungettext
 
@@ -11,10 +12,11 @@ from misago.categories.permissions import can_browse_category, can_see_category
 
 from ...events import record_event
 from ...models import Thread
+from ...moderation import threads as moderation
 from ...permissions import can_see_thread
 from ...serializers import MergeThreadsSerializer, ThreadsListSerializer
 from ...threadtypes import trees_map
-from ...utils import add_categories_to_threads
+from ...utils import add_categories_to_threads, get_thread_id_from_url
 
 
 MERGE_LIMIT = 20 # no more than 20 threads can be merged in single action
@@ -25,6 +27,49 @@ class MergeError(Exception):
         self.msg = msg
 
 
+@transaction.atomic
+def thread_merge_endpoint(request, thread, viewmodel):
+    if not thread.acl['can_merge']:
+        raise PermissionDenied(_("You don't have permission to merge this thread with others."))
+
+    other_thread_id = get_thread_id_from_url(request, request.data.get('thread_url', None))
+    if not other_thread_id:
+        return Response({'detail': _("This is not a valid thread link.")}, status=400)
+    if other_thread_id == thread.pk:
+        return Response({'detail': _("You can't merge thread with itself.")}, status=400)
+
+    try:
+        other_thread = viewmodel(request, other_thread_id).thread
+    except PermissionDenied as e:
+        return Response({
+            'detail': e.args[0]
+        }, status=400)
+    except Http404:
+        return Response({
+            'detail': _("The thread you have entered link to doesn't exist or you don't have permission to see it.")
+        }, status=400)
+
+    if not other_thread.acl['can_merge']:
+        return Response({
+            'detail': _("You don't have permission to merge this thread with current one.")
+        }, status=400)
+
+    moderation.merge_thread(request, other_thread, thread)
+
+    other_thread.category.synchronize()
+    other_thread.category.save()
+
+    if thread.category != other_thread.category:
+        thread.category.synchronize()
+        thread.category.save()
+
+    return Response({
+        'id': other_thread.pk,
+        'title': other_thread.title,
+        'url': other_thread.get_absolute_url()
+    })
+
+
 def threads_merge_endpoint(request):
     try:
         threads = clean_threads_for_merge(request)

+ 11 - 44
misago/threads/api/threads.py

@@ -3,7 +3,7 @@ from django.db import transaction
 from django.utils.translation import gettext as _
 
 from rest_framework import viewsets
-from rest_framework.decorators import list_route
+from rest_framework.decorators import detail_route, list_route
 from rest_framework.response import Response
 
 from misago.acl import add_acl
@@ -19,8 +19,9 @@ from ..subscriptions import make_subscription_aware
 from ..threadtypes import trees_map
 from ..viewmodels.thread import ForumThread
 from .postingendpoint import PostingEndpoint
+from .threadendpoints.editor import thread_start_editor
 from .threadendpoints.list import threads_list_endpoint
-from .threadendpoints.merge import threads_merge_endpoint
+from .threadendpoints.merge import thread_merge_endpoint, threads_merge_endpoint
 from .threadendpoints.patch import thread_patch_endpoint
 
 
@@ -80,8 +81,13 @@ class ThreadViewSet(ViewSet):
         else:
             return Response(posting.errors, status=400)
 
-    @list_route(methods=['post'])
-    def merge(self, request):
+    @detail_route(methods=['post'], url_path='merge')
+    def thread_merge(self, request, pk):
+        thread = self.get_thread(request, pk).thread
+        return thread_merge_endpoint(request, thread, self.thread)
+
+    @list_route(methods=['post'], url_path='merge')
+    def threads_merge(self, request):
         return threads_merge_endpoint(request)
 
     @list_route(methods=['post'])
@@ -105,43 +111,4 @@ class ThreadViewSet(ViewSet):
 
     @list_route(methods=['get'])
     def editor(self, request):
-        if request.user.is_anonymous():
-            raise PermissionDenied(_("You need to be signed in to start threads."))
-
-        # list of categories that allow or contain subcategories that allow new threads
-        available = []
-
-        categories = []
-        for category in Category.objects.filter(pk__in=request.user.acl['browseable_categories']).order_by('-lft'):
-            add_acl(request.user, category)
-
-            post = False
-            if can_start_thread(request.user, category):
-                post = {
-                    'close': bool(category.acl['can_close_threads']),
-                    'hide': bool(category.acl['can_hide_threads']),
-                    'pin': category.acl['can_pin_threads']
-                }
-
-                available.append(category.pk)
-                available.append(category.parent_id)
-            elif category.pk in available:
-                available.append(category.parent_id)
-
-            categories.append({
-                'id': category.pk,
-                'name': category.name,
-                'level': category.level - 1,
-                'post': post
-            })
-
-        # list only categories that allow new threads, or contains subcategory that allows one
-        cleaned_categories = []
-        for category in reversed(categories):
-            if category['id'] in available:
-                cleaned_categories.append(category)
-
-        if not cleaned_categories:
-            raise PermissionDenied(_("No categories that allow new threads are available to you at the moment."))
-
-        return Response(cleaned_categories)
+        return thread_start_editor(request)

+ 4 - 8
misago/threads/serializers/moderation.py

@@ -31,8 +31,7 @@ def validate_category(user, category_id, allow_root=False):
         raise ValidationError(_("Requested category could not be found."))
 
     if not can_browse_category(user, category):
-        raise ValidationError(
-            _("You don't have permission to access this category."))
+        raise ValidationError(_("You don't have permission to access this category."))
     return category
 
 
@@ -66,11 +65,9 @@ class MergeThreadsSerializer(serializers.Serializer):
 
         if weight > self.category.acl.get('can_pin_threads', 0):
             if weight == 2:
-                raise ValidationError(_("You don't have permission to pin "
-                                        "threads globally in this category."))
+                raise ValidationError(_("You don't have permission to pin threads globally in this category."))
             else:
-                raise ValidationError(_("You don't have permission to pin "
-                                        "threads in this category."))
+                raise ValidationError(_("You don't have permission to pin threads in this category."))
         return weight
 
     def validate_is_closed(self, is_closed):
@@ -80,6 +77,5 @@ class MergeThreadsSerializer(serializers.Serializer):
             return is_closed # don't validate closed further if category failed
 
         if is_closed and not self.category.acl.get('can_close_threads'):
-            raise ValidationError(_("You don't have permission to close "
-                                    "threads in this category."))
+            raise ValidationError(_("You don't have permission to close threads in this category."))
         return is_closed

+ 5 - 2
misago/threads/serializers/thread.py

@@ -85,10 +85,13 @@ class ThreadSerializer(serializers.ModelSerializer):
     def get_api(self, obj):
         return {
             'index': obj.get_api_url(),
-            'posts': reverse('misago:api:thread-post-list', kwargs={
+            'editor': reverse('misago:api:thread-post-editor', kwargs={
                 'thread_pk': obj.pk
             }),
-            'editor': reverse('misago:api:thread-post-editor', kwargs={
+            'merge': reverse('misago:api:thread-merge', kwargs={
+                'pk': obj.pk
+            }),
+            'posts': reverse('misago:api:thread-post-list', kwargs={
                 'thread_pk': obj.pk
             }),
             'read': 'nada',

+ 165 - 0
misago/threads/tests/test_thread_merge_api.py

@@ -0,0 +1,165 @@
+import json
+
+from django.core.urlresolvers import reverse
+from django.utils.encoding import smart_str
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+
+from .. import testutils
+from ..models import Thread
+from .test_threads_api import ThreadsApiTestCase
+
+class ThreadMergeApiTests(ThreadsApiTestCase):
+    def setUp(self):
+        super(ThreadMergeApiTests, self).setUp()
+
+        Category(
+            name='Category B',
+            slug='category-b',
+        ).insert_at(self.category, position='last-child', save=True)
+        self.category_b = Category.objects.get(slug='category-b')
+
+        self.api_link = reverse('misago:api:thread-merge', kwargs={'pk': self.thread.pk})
+
+    def override_other_acl(self, acl=None):
+        final_acl = {
+            'can_see': 1,
+            'can_browse': 1,
+            'can_see_all_threads': 1,
+            'can_see_own_threads': 0,
+            'can_hide_threads': 0,
+            'can_approve_content': 0,
+            'can_edit_posts': 0,
+            'can_hide_posts': 0,
+            'can_hide_own_posts': 0,
+            'can_merge_threads': 0
+        }
+        final_acl.update(acl or {})
+
+        categories_acl = self.user.acl['categories']
+        categories_acl[self.category_b.pk] = final_acl
+
+        visible_categories = [self.category.pk]
+        if final_acl['can_see']:
+            visible_categories.append(self.category_b.pk)
+
+        override_acl(self.user, {
+            'visible_categories': visible_categories,
+            'categories': categories_acl,
+        })
+
+    def test_merge_no_permission(self):
+        """api validates if thread can be merged with other one"""
+        self.override_acl({
+            'can_merge_threads': 0
+        })
+
+        response = self.client.post(self.api_link)
+        self.assertContains(response, "You don't have permission to merge this thread with others.", status_code=403)
+
+    def test_merge_no_url(self):
+        """api validates if thread url was given"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        response = self.client.post(self.api_link)
+        self.assertContains(response, "This is not a valid thread link.", status_code=400)
+
+    def test_invalid_url(self):
+        """api validates thread url"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        response = self.client.post(self.api_link, {
+            'thread_url': self.user.get_absolute_url()
+        })
+        self.assertContains(response, "This is not a valid thread link.", status_code=400)
+
+    def test_current_thread_url(self):
+        """api validates if thread url given is to current thread"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        response = self.client.post(self.api_link, {
+            'thread_url': self.thread.get_absolute_url()
+        })
+        self.assertContains(response, "You can't merge thread with itself.", status_code=400)
+
+    def test_other_thread_exists(self):
+        """api validates if other thread exists"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        self.override_other_acl()
+
+        other_thread = testutils.post_thread(self.category_b)
+        other_thread_url = other_thread.get_absolute_url()
+        other_thread.delete()
+
+        response = self.client.post(self.api_link, {
+            'thread_url': other_thread_url
+        })
+        self.assertContains(response, "The thread you have entered link to doesn't exist", status_code=400)
+
+    def test_other_thread_is_invisible(self):
+        """api validates if other thread is visible"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        self.override_other_acl({
+            'can_see': 0
+        })
+
+        other_thread = testutils.post_thread(self.category_b)
+
+        response = self.client.post(self.api_link, {
+            'thread_url': other_thread.get_absolute_url()
+        })
+        self.assertContains(response, "The thread you have entered link to doesn't exist", status_code=400)
+
+    def test_other_thread_isnt_mergeable(self):
+        """api validates if other thread can be merged"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        self.override_other_acl({
+            'can_merge_threads': 0
+        })
+
+        other_thread = testutils.post_thread(self.category_b)
+
+        response = self.client.post(self.api_link, {
+            'thread_url': other_thread.get_absolute_url()
+        })
+        self.assertContains(response, "You don't have permission to merge this thread", status_code=400)
+
+    def test_merge_threads(self):
+        """api merges two threads successfully"""
+        self.override_acl({
+            'can_merge_threads': 1
+        })
+
+        self.override_other_acl({
+            'can_merge_threads': 1
+        })
+
+        other_thread = testutils.post_thread(self.category_b)
+
+        response = self.client.post(self.api_link, {
+            'thread_url': other_thread.get_absolute_url()
+        })
+        self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
+
+        # other thread has two posts now
+        self.assertEqual(other_thread.post_set.count(), 3)
+
+        # first thread is gone
+        with self.assertRaises(Thread.DoesNotExist):
+            Thread.objects.get(pk=self.thread.pk)

+ 2 - 1
misago/threads/tests/test_threads_api.py

@@ -35,6 +35,7 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
             'can_edit_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
+            'can_merge_threads': 0
         })
 
         if acl:
@@ -78,7 +79,7 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             if 'posts' in link:
                 self.assertIn('post_set', response_json)
 
-    def test_api_shows_owner_thread(self):
+    def test_api_shows_owned_thread(self):
         """api handles "owned threads only"""
         for link in self.tested_links:
             self.override_acl({

+ 1 - 1
misago/threads/tests/test_utils.py

@@ -152,7 +152,7 @@ class MockRequest(object):
         self.host = host
 
         self.path_info = '/api/threads/123/merge/'
-        self.path = '{}'.format(wsgialias.rstrip('/'), self.path_info)
+        self.path = '{}{}'.format(wsgialias.rstrip('/'), self.path_info)
 
     def get_host(self):
         return self.host

+ 1 - 1
misago/threads/utils.py

@@ -66,7 +66,7 @@ def get_thread_id_from_url(request, url):
         clean_path = bits.path
 
     try:
-        wsgi_alias = request.path[:len(request.path_info)]
+        wsgi_alias = request.path[:len(request.path_info) * -1]
         resolution = resolve(clean_path[len(wsgi_alias):])
     except:
         return None