Browse Source

api/threads/merge/ endpoint

Rafał Pitoń 9 years ago
parent
commit
5bc22a0e57

+ 1 - 1
misago/categories/models.py

@@ -38,7 +38,7 @@ class CategoryManager(TreeManager):
     def all_categories(self, include_root=False):
         qs = self.filter(tree_id=CATEGORIES_TREE_ID)
         if not include_root:
-            qs = self.filter(level__gt=0)
+            qs = qs.filter(level__gt=0)
         return qs.order_by('lft')
 
     def get_cached_categories_dict(self):

+ 131 - 0
misago/threads/api/threadendpoints/merge.py

@@ -0,0 +1,131 @@
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.utils.translation import gettext as _, ungettext
+
+from rest_framework.response import Response
+
+from misago.acl import add_acl
+from misago.categories.models import CATEGORIES_TREE_ID, Category
+from misago.categories.permissions import can_see_category, can_browse_category
+
+from misago.threads.models import Thread
+from misago.threads.permissions import can_see_thread
+from misago.threads.serializers import (
+    ThreadListSerializer, MergeThreadsSerializer)
+from misago.threads.utils import add_categories_to_threads
+
+
+MERGE_LIMIT = 20 # no more than 20 threads can be merged in single action
+
+
+class MergeError(Exception):
+    def __init__(self, msg):
+        self.msg = msg
+
+
+def threads_merge_endpoint(request):
+    try:
+        threads = clean_threads_for_merge(request)
+    except MergeError as e:
+        return Response({'detail': e.msg}, status=403)
+
+    invalid_threads = []
+    for thread in threads:
+        if not thread.acl['can_merge']:
+            invalid_threads.append({
+                'id': thread.pk,
+                'title': thread.title,
+                'errors': [
+                    _("You don't have permission to merge this thread with others.")
+                ]
+            })
+
+    if invalid_threads:
+        return Response(invalid_threads, status=403)
+
+    serializer = MergeThreadsSerializer(context=request.user, data=request.data)
+    if serializer.is_valid():
+        new_thread = merge_threads(
+            request.user, serializer.validated_data, threads)
+        return Response(ThreadListSerializer(new_thread).data)
+    else:
+        return Response(serializer.errors, status=400)
+
+
+def clean_threads_for_merge(request):
+    try:
+        threads_ids = map(int, request.data.get('threads', []))
+    except (ValueError, TypeError):
+        raise MergeError(_("One or more thread ids received were invalid."))
+
+    if len(threads_ids) < 2:
+        raise MergeError(_("You have to select at least two threads to merge."))
+    elif len(threads_ids) > MERGE_LIMIT:
+        message = ungettext(
+            "No more than %(limit)s thread can be merged at single time.",
+            "No more than %(limit)s threads can be merged at single time.",
+            MERGE_LIMIT)
+        raise MergeError(message % {'limit': settings.thread_title_length_max})
+
+    threads_queryset = Thread.objects.filter(
+        id__in=threads_ids,
+        category__tree_id=CATEGORIES_TREE_ID,
+    ).select_related('category').order_by('-id')
+
+    threads = []
+    for thread in threads_queryset:
+        add_acl(request.user, thread)
+        if can_see_thread(request.user, thread):
+            threads.append(thread)
+
+    if len(threads) != len(threads_ids):
+        raise MergeError(_("One or more threads to merge could not be found."))
+
+    return threads
+
+
+@transaction.atomic
+def merge_threads(user, validated_data, threads):
+    new_thread = Thread(
+        category=validated_data['category'],
+        weight=validated_data.get('weight', 0),
+        is_closed=validated_data.get('is_closed', False),
+        started_on=threads[0].started_on,
+        last_post_on=threads[0].last_post_on,
+    )
+
+    new_thread.set_title(validated_data['title'])
+    new_thread.save()
+
+    categories = []
+    for thread in threads:
+        categories.append(thread.category)
+        new_thread.merge(thread)
+        thread.delete()
+
+    new_thread.synchronize()
+    new_thread.save()
+
+    if new_thread.category not in categories:
+        categories.append(new_thread.category)
+
+    for category in categories:
+        category.synchronize()
+        category.save()
+
+    # set extra attrs on thread for UI
+    new_thread.is_read = False
+    new_thread.subscription = None
+
+    # add top category to thread
+    if validated_data.get('top_category'):
+        categories = list(Category.objects.all_categories().filter(
+            id__in=user.acl['visible_categories']
+        ))
+        add_categories_to_threads(
+            validated_data['top_category'], categories, [new_thread])
+    else:
+        new_thread.top_category = None
+
+    new_thread.save()
+    return new_thread

+ 4 - 2
misago/threads/api/threadendpoints/patch.py

@@ -10,6 +10,7 @@ from misago.core.apipatch import ApiPatch
 from misago.core.shortcuts import get_int_or_404, get_object_or_404
 
 from misago.threads.moderation import threads as moderation
+from misago.threads.permissions import allow_start_thread
 from misago.threads.utils import add_categories_to_threads
 
 
@@ -49,6 +50,7 @@ def patch_move(request, thread, value):
         add_acl(request.user, new_category)
         allow_see_category(request.user, new_category)
         allow_browse_category(request.user, new_category)
+        allow_start_thread(request.user, new_category)
 
         moderation.move_thread(request.user, thread, new_category)
 
@@ -70,7 +72,6 @@ def patch_top_category(request, thread, value):
         id__in=request.user.acl['visible_categories']
     ))
     add_categories_to_threads(root_category, categories, [thread])
-
     return {'top_category': CategorySerializer(thread.top_category).data}
 thread_patch_endpoint.add('top-category', patch_top_category)
 
@@ -81,9 +82,10 @@ def patch_flatten_categories(request, thread, value):
             'category': thread.category_id,
             'top_category': thread.top_category.pk,
         }
-    except AttributeError:
+    except AttributeError as e:
         return {
             'category': thread.category_id,
+            'top_category': None
         }
 thread_patch_endpoint.replace('flatten-categories', patch_flatten_categories)
 

+ 5 - 0
misago/threads/api/threads.py

@@ -16,6 +16,7 @@ from misago.readtracker.categoriestracker import read_category
 from misago.users.rest_permissions import IsAuthenticatedOrReadOnly
 
 from misago.threads.api.threadendpoints.list import threads_list_endpoint
+from misago.threads.api.threadendpoints.merge import threads_merge_endpoint
 from misago.threads.api.threadendpoints.patch import thread_patch_endpoint
 from misago.threads.models import Thread, Subscription
 from misago.threads.moderation import threads as moderation
@@ -84,3 +85,7 @@ class ThreadViewSet(viewsets.ViewSet):
 
         read_category(request.user, category)
         return Response({'detail': 'ok'})
+
+    @list_route(methods=['post'])
+    def merge(self, request):
+        return threads_merge_endpoint(request)

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

@@ -1,6 +1,6 @@
 # flake8: noqa
 from misago.threads.models.post import Post
-from misago.threads.models.thread import Thread
+from misago.threads.models.thread import *
 from misago.threads.models.threadparticipant import ThreadParticipant
 from misago.threads.models.event import Event
 from misago.threads.models.subscription import Subscription

+ 21 - 18
misago/threads/permissions/threads.py

@@ -358,24 +358,29 @@ def add_acl_to_thread(user, thread):
         'can_reply': can_reply_thread(user, thread),
         'can_edit': can_edit_thread(user, thread),
         'can_hide': category_acl.get('can_hide_threads', False),
-        'can_pin': category_acl.get('can_pin_threads', 0),
+        'can_pin': 0,
         'can_close': category_acl.get('can_close_threads', False),
-        'can_move': category_acl.get('can_move_threads', False),
+        'can_move': False,
+        'can_merge': False,
         'can_approve': category_acl.get('can_approve_content', False),
         'can_report': category_acl.get('can_report_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
     })
 
-    if can_change_owned_thread(user, thread):
-        if not category_acl.get('can_close_threads'):
-            thread_is_protected = thread.is_closed or thread.category.is_closed
-        else:
-            thread_is_protected = False
+    if not category_acl.get('can_close_threads'):
+        thread_is_protected = thread.is_closed or thread.category.is_closed
+    else:
+        thread_is_protected = False
+
+    if (can_change_owned_thread(user, thread) and not thread_is_protected
+            and not thread.replies and not thread.acl['can_hide']):
+        can_hide_thread = category_acl.get('can_hide_own_threads')
+        thread.acl['can_hide'] = can_hide_thread
 
-        if not thread_is_protected and not thread.acl['can_hide']:
-            if not thread.replies:
-                can_hide_thread = category_acl.get('can_hide_own_threads')
-                thread.acl['can_hide'] = can_hide_thread
+    if not thread_is_protected:
+        thread.acl['can_pin'] = category_acl.get('can_pin_threads', 0)
+        thread.acl['can_move'] = category_acl.get('can_move_threads', False)
+        thread.acl['can_merge'] = category_acl.get('can_merge_threads', False)
 
 
 def add_acl_to_post(user, post):
@@ -453,9 +458,9 @@ def allow_reply_thread(user, target):
     if user.is_anonymous():
         raise PermissionDenied(_("You have to sign in to reply threads."))
 
-    category_acl = target.category.acl
+    category_acl = user.acl['categories'].get(target.category_id, {})
 
-    if not category_acl['can_close_threads']:
+    if not category_acl.get('can_close_threads', False):
         if target.category.is_closed:
             raise PermissionDenied(
                 _("This category is closed. You can't reply to threads in it."))
@@ -463,7 +468,7 @@ def allow_reply_thread(user, target):
             raise PermissionDenied(
                 _("You can't reply to closed threads in this category."))
 
-    if not category_acl['can_reply_threads']:
+    if not category_acl.get('can_reply_threads', False):
         raise PermissionDenied(_("You can't reply to threads in this category."))
 can_reply_thread = return_boolean(allow_reply_thread)
 
@@ -472,9 +477,9 @@ def allow_edit_thread(user, target):
     if user.is_anonymous():
         raise PermissionDenied(_("You have to sign in to edit threads."))
 
-    category_acl = target.category.acl
+    category_acl = user.acl['categories'].get(target.category_id, {})
 
-    if not category_acl['can_edit_threads']:
+    if not category_acl.get('can_edit_threads', False):
         raise PermissionDenied(_("You can't edit threads in this category."))
 
     if category_acl['can_edit_threads'] == 1:
@@ -679,8 +684,6 @@ can_delete_post = return_boolean(allow_delete_post)
 Permission check helpers
 """
 def can_change_owned_thread(user, target):
-    category_acl = user.acl['categories'].get(target.category_id, {})
-
     if user.is_anonymous() or user.pk != target.starter_id:
         return False
 

+ 2 - 1
misago/threads/serializers/__init__.py

@@ -1 +1,2 @@
-from misago.threads.serializers.thread import *
+from misago.threads.serializers.thread import *
+from misago.threads.serializers.moderation import *

+ 76 - 0
misago/threads/serializers/moderation.py

@@ -0,0 +1,76 @@
+from django.forms import ValidationError
+from django.utils.translation import ugettext as _
+
+from rest_framework import serializers
+
+from misago.acl import add_acl
+from misago.categories.models import Category
+from misago.categories.permissions import can_see_category, can_browse_category
+
+from misago.threads.models import THREAD_WEIGHT_DEFAULT, THREAD_WEIGHT_GLOBAL
+from misago.threads.permissions import allow_start_thread
+from misago.threads.validators import validate_title
+
+
+def validate_category(user, category_id):
+    try:
+        category = Category.objects.all_categories().get(id=category_id)
+    except Category.DoesNotExist:
+        category = None
+
+    if not category or not can_see_category(user, category):
+        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."))
+    return category
+
+
+class MergeThreadsSerializer(serializers.Serializer):
+    title = serializers.CharField()
+    category = serializers.IntegerField()
+    top_category = serializers.IntegerField(required=False, allow_null=True)
+    weight = serializers.IntegerField(
+        required=False,
+        allow_null=True,
+        max_value=THREAD_WEIGHT_GLOBAL,
+        min_value=THREAD_WEIGHT_DEFAULT,
+    )
+    is_closed = serializers.NullBooleanField(required=False)
+
+    def validate_title(self, title):
+        return validate_title(title)
+
+    def validate_top_category(self, category_id):
+        return validate_category(self.context, category_id)
+
+    def validate_category(self, category_id):
+        self.category = validate_category(self.context, category_id)
+        return self.category
+
+    def validate_weight(self, weight):
+        try:
+            add_acl(self.context, self.category)
+        except AttributeError:
+            return weight # don't validate weight further if category failed
+
+        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."))
+            else:
+                raise ValidationError(_("You don't have permission to pin "
+                                        "threads in this category."))
+        return weight
+
+    def validate_is_closed(self, is_closed):
+        try:
+            add_acl(self.context, self.category)
+        except AttributeError:
+            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."))
+        return is_closed

+ 42 - 10
misago/threads/tests/test_thread_patch_api.py

@@ -3,10 +3,10 @@ import json
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 
-from misago.threads.tests.test_thread_api import ThreadApiTestCase
+from misago.threads.tests.test_threads_api import ThreadsApiTestCase
 
 
-class ThreadPinGloballyApiTests(ThreadApiTestCase):
+class ThreadPinGloballyApiTests(ThreadsApiTestCase):
     def test_pin_thread(self):
         """api makes it possible to pin globally thread"""
         self.override_acl({
@@ -88,7 +88,7 @@ class ThreadPinGloballyApiTests(ThreadApiTestCase):
         self.assertEqual(thread_json['weight'], 2)
 
 
-class ThreadPinLocallyApiTests(ThreadApiTestCase):
+class ThreadPinLocallyApiTests(ThreadsApiTestCase):
     def test_pin_thread(self):
         """api makes it possible to pin locally thread"""
         self.override_acl({
@@ -170,7 +170,7 @@ class ThreadPinLocallyApiTests(ThreadApiTestCase):
         self.assertEqual(thread_json['weight'], 1)
 
 
-class ThreadMoveApiTests(ThreadApiTestCase):
+class ThreadMoveApiTests(ThreadsApiTestCase):
     def setUp(self):
         super(ThreadMoveApiTests, self).setUp()
 
@@ -203,8 +203,8 @@ class ThreadMoveApiTests(ThreadApiTestCase):
             'categories': categories_acl,
         })
 
-    def test_move_thread(self):
-        """api moves thread to other category"""
+    def test_move_thread_no_top(self):
+        """api moves thread to other category, sets no top category"""
         self.override_acl({
             'can_move_threads': True
         })
@@ -223,6 +223,38 @@ class ThreadMoveApiTests(ThreadApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category_b.pk)
 
+        reponse_json = json.loads(response.content)
+        self.assertEqual(reponse_json['category'], self.category_b.pk)
+        self.assertEqual(reponse_json['top_category'], None)
+
+    def test_move_thread_with_top(self):
+        """api moves thread to other category, sets top"""
+        self.override_acl({
+            'can_move_threads': True
+        })
+        self.override_other_acl({})
+
+        response = self.client.patch(self.api_link, json.dumps([
+            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk},
+            {
+                'op': 'add',
+                'path': 'top-category',
+                'value': Category.objects.root_category().pk,
+            },
+            {'op': 'replace', 'path': 'flatten-categories', 'value': None},
+        ]),
+        content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        self.override_other_acl({})
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['category']['id'], self.category_b.pk)
+
+        reponse_json = json.loads(response.content)
+        self.assertEqual(reponse_json['category'], self.category_b.pk)
+        self.assertEqual(reponse_json['top_category'], self.category.pk)
+
     def test_move_thread_no_permission(self):
         """api move thread to other category with no permission fails"""
         self.override_acl({
@@ -326,7 +358,7 @@ class ThreadMoveApiTests(ThreadApiTestCase):
         self.assertEqual(response_json['category'], self.category_b.pk)
 
 
-class ThreadCloseApiTests(ThreadApiTestCase):
+class ThreadCloseApiTests(ThreadsApiTestCase):
     def test_close_thread(self):
         """api makes it possible to close thread"""
         self.override_acl({
@@ -408,7 +440,7 @@ class ThreadCloseApiTests(ThreadApiTestCase):
         self.assertTrue(thread_json['is_closed'])
 
 
-class ThreadApproveApiTests(ThreadApiTestCase):
+class ThreadApproveApiTests(ThreadsApiTestCase):
     def test_approve_thread(self):
         """api makes it possible to approve thread"""
         self.thread.is_unapproved = True
@@ -444,7 +476,7 @@ class ThreadApproveApiTests(ThreadApiTestCase):
             "Content approval can't be reversed.")
 
 
-class ThreadHideApiTests(ThreadApiTestCase):
+class ThreadHideApiTests(ThreadsApiTestCase):
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
         self.override_acl({
@@ -535,7 +567,7 @@ class ThreadHideApiTests(ThreadApiTestCase):
         self.assertEqual(response.status_code, 404)
 
 
-class ThreadSubscribeApiTests(ThreadApiTestCase):
+class ThreadSubscribeApiTests(ThreadsApiTestCase):
     def test_subscribe_thread(self):
         """api makes it possible to subscribe thread"""
         response = self.client.patch(self.api_link, json.dumps([

+ 4 - 4
misago/threads/tests/test_thread_api.py → misago/threads/tests/test_threads_api.py

@@ -8,9 +8,9 @@ from misago.threads import testutils
 from misago.threads.models import Thread
 
 
-class ThreadApiTestCase(AuthenticatedUserTestCase):
+class ThreadsApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
-        super(ThreadApiTestCase, self).setUp()
+        super(ThreadsApiTestCase, self).setUp()
 
         self.category = Category.objects.get(slug='first-category')
 
@@ -41,7 +41,7 @@ class ThreadApiTestCase(AuthenticatedUserTestCase):
         return json.loads(response.content)
 
 
-class ThreadDeleteApiTests(ThreadApiTestCase):
+class ThreadDeleteApiTests(ThreadsApiTestCase):
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
         self.override_acl({
@@ -75,7 +75,7 @@ class ThreadDeleteApiTests(ThreadApiTestCase):
         self.assertEqual(response.status_code, 403)
 
 
-class ThreadsReadApiTests(ThreadApiTestCase):
+class ThreadsReadApiTests(ThreadsApiTestCase):
     def setUp(self):
         super(ThreadsReadApiTests, self).setUp()
         self.api_link = '/api/threads/read/'

+ 457 - 0
misago/threads/tests/test_threads_merge_api.py

@@ -0,0 +1,457 @@
+import json
+
+from django.core.urlresolvers import reverse
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+
+from misago.threads import testutils
+from misago.threads.api.threadendpoints.merge import MERGE_LIMIT
+from misago.threads.models import Thread, Post
+from misago.threads.serializers import ThreadListSerializer
+from misago.threads.tests.test_threads_api import ThreadsApiTestCase
+
+
+class ThreadsMergeApiTests(ThreadsApiTestCase):
+    def setUp(self):
+        super(ThreadsMergeApiTests, self).setUp()
+        self.api_link = reverse('misago:api:thread-merge')
+
+        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')
+
+    def test_merge_no_threads(self):
+        """api validates if we are trying to merge no threads"""
+        response = self.client.post(
+            self.api_link, content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "You have to select at least two threads to merge."
+        })
+
+    def test_merge_empty_threads(self):
+        """api validates if we are trying to empty threads list"""
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': []
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "You have to select at least two threads to merge."
+        })
+
+    def test_merge_invalid_threads(self):
+        """api validates if we are trying to merge invalid thread ids"""
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': 'abcd'
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "One or more thread ids received were invalid."
+        })
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': ['a', '-', 'c']
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "One or more thread ids received were invalid."
+        })
+
+    def test_merge_single_thread(self):
+        """api validates if we are trying to merge single thread"""
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "You have to select at least two threads to merge."
+        })
+
+    def test_merge_with_nonexisting_thread(self):
+        """api validates if we are trying to merge with invalid thread"""
+        unaccesible_thread = testutils.post_thread(category=self.category_b)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, self.thread.id + 1000]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "One or more threads to merge could not be found."
+        })
+
+    def test_merge_with_invisible_thread(self):
+        """api validates if we are trying to merge with inaccesible thread"""
+        unaccesible_thread = testutils.post_thread(category=self.category_b)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, unaccesible_thread.id]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'detail': "One or more threads to merge could not be found."
+        })
+
+    def test_merge_no_permission(self):
+        """api validates permission to merge threads"""
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 403)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, [
+            {
+                'id': thread.pk,
+                'title': thread.title,
+                'errors': [
+                    "You don't have permission to merge this thread with others."
+                ]
+            },
+            {
+                'id': self.thread.pk,
+                'title': self.thread.title,
+                'errors': [
+                    "You don't have permission to merge this thread with others."
+                ]
+            },
+        ])
+
+    def test_merge_no_final_thread(self):
+        """api rejects merge because no data to merge threads was specified"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id]
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'title': ['This field is required.'],
+            'category': ['This field is required.'],
+        })
+
+    def test_merge_invalid_final_title(self):
+        """api rejects merge because final thread title was invalid"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': '$$$',
+            'category': self.category.id,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'title': ["Thread title should be at least 5 characters long."]
+        })
+
+    def test_merge_invalid_category(self):
+        """api rejects merge because final category was invalid"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Valid thread title',
+            'category': self.category_b.id,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'category': ["Requested category could not be found."]
+        })
+
+    def test_merge_invalid_weight(self):
+        """api rejects merge because final weight was invalid"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Valid thread title',
+            'category': self.category.id,
+            'weight': 4,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'weight': ["Ensure this value is less than or equal to 2."]
+        })
+
+    def test_merge_unallowed_global_weight(self):
+        """api rejects merge because global weight was unallowed"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Valid thread title',
+            'category': self.category.id,
+            'weight': 2,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'weight': [
+                "You don't have permission to pin threads globally in this category."
+            ]
+        })
+
+    def test_merge_unallowed_local_weight(self):
+        """api rejects merge because local weight was unallowed"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Valid thread title',
+            'category': self.category.id,
+            'weight': 1,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'weight': [
+                "You don't have permission to pin threads in this category."
+            ]
+        })
+
+    def test_merge_allowed_local_weight(self):
+        """api allows local weight"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+            'can_pin_threads': 1,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': '$$$',
+            'category': self.category.id,
+            'weight': 1,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'title': ["Thread title should be at least 5 characters long."]
+        })
+
+    def test_merge_allowed_global_weight(self):
+        """api allows local weight"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+            'can_pin_threads': 2,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': '$$$',
+            'category': self.category.id,
+            'weight': 2,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'title': ["Thread title should be at least 5 characters long."]
+        })
+
+    def test_merge_unallowed_close(self):
+        """api rejects merge because closing thread was unallowed"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Valid thread title',
+            'category': self.category.id,
+            'is_closed': True,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'is_closed': [
+                "You don't have permission to close threads in this category."
+            ]
+        })
+
+    def test_merge_with_close(self):
+        """api allows for closing thread"""
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+            'can_close_threads': True,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': '$$$',
+            'category': self.category.id,
+            'weight': 0,
+            'is_closed': True,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 400)
+
+        response_json = json.loads(response.content)
+        self.assertEqual(response_json, {
+            'title': ["Thread title should be at least 5 characters long."]
+        })
+
+    def test_merge(self):
+        """api performs basic merge"""
+        posts_ids = [p.id for p in Post.objects.all()]
+
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Merged thread!',
+            'category': self.category.id,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        # is response json with new thread?
+        response_json = json.loads(response.content)
+
+        new_thread = Thread.objects.get(pk=response_json['id'])
+        new_thread.is_read = False
+        new_thread.subscription = None
+        new_thread.top_category = None
+
+        self.assertEqual(response_json, ThreadListSerializer(new_thread).data)
+
+        # did posts move to new thread?
+        for post in Post.objects.filter(id__in=posts_ids):
+            self.assertEqual(post.thread_id, new_thread.id)
+
+        # are old threads gone?
+        self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
+
+    def test_merge_with_top_category(self):
+        """api performs merge with top category"""
+        posts_ids = [p.id for p in Post.objects.all()]
+
+        self.override_acl({
+            'can_merge_threads': True,
+            'can_close_threads': False,
+            'can_edit_threads': False,
+            'can_reply_threads': False,
+        })
+
+        thread = testutils.post_thread(category=self.category)
+
+        response = self.client.post(self.api_link, json.dumps({
+            'threads': [self.thread.id, thread.id],
+            'title': 'Merged thread!',
+            'category': self.category.id,
+            'top_category': self.category.id,
+        }), content_type="application/json")
+        self.assertEqual(response.status_code, 200)
+
+        # is response json with new thread?
+        response_json = json.loads(response.content)
+
+        new_thread = Thread.objects.get(pk=response_json['id'])
+        new_thread.is_read = False
+        new_thread.subscription = None
+        new_thread.top_category = None
+
+        self.assertEqual(response_json, ThreadListSerializer(new_thread).data)
+
+        # did posts move to new thread?
+        for post in Post.objects.filter(id__in=posts_ids):
+            self.assertEqual(post.thread_id, new_thread.id)
+
+        # are old threads gone?
+        self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])