Просмотр исходного кода

Mark post as best answer patch op, small tweaks in patch actions

Rafał Pitoń 7 лет назад
Родитель
Сommit
6db3c702c9

+ 67 - 7
misago/threads/api/threadendpoints/patch.py

@@ -19,13 +19,13 @@ from misago.threads.moderation import threads as moderation
 from misago.threads.participants import (
     add_participant, change_owner, make_participants_aware, remove_participant)
 from misago.threads.permissions import (
-    allow_add_participant, allow_add_participants, allow_approve_thread, allow_change_owner, allow_edit_thread,
-    allow_pin_thread, allow_hide_thread, allow_move_thread, allow_remove_participant, allow_start_thread,
-    allow_unhide_thread)
+    allow_add_participant, allow_add_participants, allow_approve_thread, allow_change_best_answer,
+    allow_change_owner, allow_edit_thread, allow_pin_thread, allow_hide_thread, allow_mark_as_best_answer,
+    allow_mark_best_answer, allow_move_thread, allow_remove_participant, allow_see_post,
+    allow_start_thread, allow_unhide_thread, allow_unmark_best_answer)
 from misago.threads.serializers import ThreadParticipantSerializer
 from misago.threads.validators import validate_title
 
-
 PATCH_LIMIT = settings.MISAGO_THREADS_PER_PAGE + settings.MISAGO_THREADS_TAIL
 
 UserModel = get_user_model()
@@ -49,7 +49,7 @@ def patch_title(request, thread, value):
     try:
         value_cleaned = six.text_type(value).strip()
     except (TypeError, ValueError):
-        raise PermissionDenied(_("Invalid thread title."))
+        raise PermissionDenied(_('Not a valid string.'))
 
     try:
         validate_title(value_cleaned)
@@ -198,6 +198,66 @@ def patch_subscription(request, thread, value):
 thread_patch_dispatcher.replace('subscription', patch_subscription)
 
 
+def patch_best_answer(request, thread, value):
+    try:
+        post_id = int(value)
+    except (TypeError, ValueError):
+        raise PermissionDenied(_("A valid integer is required."))
+
+    allow_mark_best_answer(request.user, thread)
+
+    post = get_object_or_404(thread.post_set, id=post_id)
+    post.category = thread.category
+    post.thread = thread
+
+    allow_see_post(request.user, post)
+    allow_mark_as_best_answer(request.user, post)
+
+    if post.is_best_answer:
+        raise PermissionDenied(_("This post is already marked as best answer."))
+
+    if thread.best_answer_id:
+        allow_change_best_answer(request.user, thread)
+        
+    thread.set_best_answer(request.user, post)
+    thread.save()
+
+    return {
+        'best_answer': thread.best_answer_id,
+        'best_answer_is_protected': thread.best_answer_is_protected,
+        'best_answer_marked_on': thread.best_answer_marked_on,
+        'best_answer_marked_by': thread.best_answer_marked_by_id,
+        'best_answer_marked_by_name': thread.best_answer_marked_by_name,
+        'best_answer_marked_by_slug': thread.best_answer_marked_by_slug,
+    }
+
+
+thread_patch_dispatcher.replace('best-answer', patch_best_answer)
+
+
+def patch_unmark_best_answer(request, thread, value):
+    try:
+        post_id = int(value)
+    except (TypeError, ValueError):
+        raise PermissionDenied(_("A valid integer is required."))
+
+    allow_unmark_best_answer(request.user, thread)
+    thread.clear_best_answer()
+    thread.save()
+
+    return {
+        'best_answer': None,
+        'best_answer_is_protected': False,
+        'best_answer_marked_on': None,
+        'best_answer_marked_by': None,
+        'best_answer_marked_by_name': None,
+        'best_answer_marked_by_slug': None,
+    }
+
+
+thread_patch_dispatcher.remove('best-answer', patch_unmark_best_answer)
+
+
 def patch_add_participant(request, thread, value):
     allow_add_participants(request.user, thread)
 
@@ -228,7 +288,7 @@ def patch_remove_participant(request, thread, value):
     try:
         user_id = int(value)
     except (ValueError, TypeError):
-        user_id = 0
+        raise PermissionDenied(_("A valid integer is required."))
 
     for participant in thread.participants_list:
         if participant.user_id == user_id:
@@ -258,7 +318,7 @@ def patch_replace_owner(request, thread, value):
     try:
         user_id = int(value)
     except (ValueError, TypeError):
-        user_id = 0
+        raise PermissionDenied(_("A valid integer is required."))
 
     for participant in thread.participants_list:
         if participant.user_id == user_id:

+ 131 - 84
misago/threads/permissions/bestanswers.py

@@ -8,10 +8,12 @@ from misago.acl.decorators import return_boolean
 from misago.categories.models import Category, CategoryRole
 from misago.categories.permissions import get_categories_roles
 from misago.core.forms import YesNoSwitch
-from misago.threads.models import Post
+from misago.threads.models import Post, Thread
 
 
 __all__nope = [
+    'allow_mark_best_answer',
+    'can_mark_best_answer',
     'allow_mark_as_best_answer',
     'can_mark_as_best_answer',
     'allow_unmark_best_answer',
@@ -106,26 +108,31 @@ def build_category_acl(acl, category, categories_roles, key_name):
     return final_acl
 
 
+def add_acl_to_thread(user, thread):
+    thread.acl.update({
+        'can_mark_best_answer': can_mark_best_answer(user, thread),
+        'can_change_best_answer': can_change_best_answer(user, thread),
+        'can_unmark_best_answer': can_unmark_best_answer(user, thread),
+    })
+    
+
 def add_acl_to_post(user, post):
     post.acl.update({
         'can_mark_as_best_answer': can_mark_as_best_answer(user, post),
-        'can_unmark_best_answer': can_unmark_best_answer(user, post),
         'can_hide_best_answer': can_hide_best_answer(user, post),
         'can_delete_best_answer': can_delete_best_answer(user, post),
     })
 
 
 def register_with(registry):
+    registry.acl_annotator(Thread, add_acl_to_thread)
     registry.acl_annotator(Post, add_acl_to_post)
 
 
-def allow_mark_as_best_answer(user, target):
+def allow_mark_best_answer(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to mark best answers."))
 
-    if target.is_event:
-        raise PermissionDenied(_("Events can't be marked as best answers."))
-
     category_acl = user.acl_cache['categories'].get(
         target.category_id, {
             'can_mark_best_answers': 0,
@@ -141,104 +148,100 @@ def allow_mark_as_best_answer(user, target):
             }
         )
 
-    if category_acl['can_mark_best_answers'] == 1 and target.thread.starter != user:
+    if category_acl['can_mark_best_answers'] == 1 and target.starter_id != user.id:
         raise PermissionDenied(
             _(
-                "You don't have permission to mark best answer in this thread because you "
-                "didn't start it."
+                "You don't have permission to mark best answer in this thread because you didn't "
+                "start it."
             )
         )
-
-    if target.is_first_post:
-        raise PermissionDenied(_("First post in a thread can't be marked as best answer."))
-
-    if target.is_hidden:
-        raise PermissionDenied(_("Hidden posts can't be marked as best answers."))
-
-    if target.is_unapproved:
-        raise PermissionDenied(_("Unapproved posts can't be marked as best answers."))
-
-    if target.is_answer:
-        raise PermissionDenied(_("This post is already marked as best answer."))
-
-    if target.thread.best_answer_id:
-        if not category_acl['can_change_marked_answers']:
-            raise PermissionDenied(
-                _(
-                    'You don\'t have permission to change marked best answers in the '
-                    '"%(category)s" category.'
-                ) % {
-                    'category': target.category,
-                }
-            )
-
-        if (category_acl['can_change_marked_answers'] == 1 and
-                not has_time_to_change_answer(user, target)):
-            raise PermissionDenied(
-                ungettext(
-                    (
-                        "You don't have permission to change best answer that was marked for more "
-                        "than %(minutes)s minute."
-                    ),
-                    (
-                        "You don't have permission to change best answer that was marked for more "
-                        "than %(minutes)s minutes."
-                    ),
-                    category_acl['answer_change_time'],
-                ) % {
-                    'minutes': category_acl['answer_change_time'],
-                }
-            )
-
-        if target.thread.best_answer_is_protected and not category_acl['can_protect_posts']:
-            raise PermissionDenied(
-                _(
-                    "You don't have permission to change this thread's marked best answer because "
-                    "a moderator has protected it."
-                )
-            )
-        
+    
     if not category_acl['can_close_threads']:
         if target.category.is_closed:
             raise PermissionDenied(
                 _(
-                    'You don\'t have permission to mark this post as best answer because its '
+                    'You don\'t have permission to mark best answer in this thread because its '
                     'category "%(category)s" is closed.'
                 ) % {
                     'category': target.category,
                 }
             )
-        if target.thread.is_closed:
+        if target.is_closed:
             raise PermissionDenied(
                 _(
-                    "You can't mark this post as best answer because its thread is closed and you "
-                    "don't have permission to open it."
+                    "You can't mark best answer in this thread because it's closed and you don't "
+                    "have permission to open it."
                 )
             )
 
-    if target.is_protected and not category_acl['can_protect_posts']:
+
+can_mark_best_answer = return_boolean(allow_mark_best_answer)
+
+
+def allow_change_best_answer(user, target):
+    if not target.best_answer_id:
+        return # shortcircut permission test
+
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_change_marked_answers': 0,
+        }
+    )
+
+    if not category_acl['can_change_marked_answers']:
         raise PermissionDenied(
             _(
-                "You don't have permission to mark this post as best answer because a moderator "
-                "has protected it."
+                'You don\'t have permission to change this thread\' marked answer because it\'s '
+                'in the "%(category)s" category.'
+            ) % {
+                'category': target.category,
+            }
+        )
+
+    if (category_acl['can_change_marked_answers'] == 1 and
+            not has_time_to_change_answer(user, target)):
+        raise PermissionDenied(
+            ungettext(
+                (
+                    "You don't have permission to change best answer that was marked for more "
+                    "than %(minutes)s minute."
+                ),
+                (
+                    "You don't have permission to change best answer that was marked for more "
+                    "than %(minutes)s minutes."
+                ),
+                category_acl['answer_change_time'],
+            ) % {
+                'minutes': category_acl['answer_change_time'],
+            }
+        )
+
+    if target.thread.best_answer_is_protected and not category_acl['can_protect_posts']:
+        raise PermissionDenied(
+            _(
+                "You don't have permission to change this thread's best answer because "
+                "a moderator has protected it."
             )
         )
 
 
-can_mark_as_best_answer = return_boolean(allow_mark_as_best_answer)
+can_change_best_answer = return_boolean(allow_change_best_answer)
 
 
 def allow_unmark_best_answer(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to unmark best answers."))
 
+    if not target.best_answer_id:
+        return # shortcircut test
+
     category_acl = user.acl_cache['categories'].get(
         target.category_id, {
             'can_mark_best_answers': 0,
         }
     )
 
-    if not category_acl['can_mark_best_answers']:
+    if not category_acl['can_change_marked_answers']:
         raise PermissionDenied(
             _(
                 'You don\'t have permission to unmark threads answers in the "%(category)s" '
@@ -248,15 +251,8 @@ def allow_unmark_best_answer(user, target):
             }
         )
 
-    if not target.is_answer:
-        raise PermissionDenied(
-            _(
-                "This post can't be unmarked because it's not currently marked as best answer."
-            )
-        )
-
-    if category_acl['can_mark_best_answers'] == 1:
-        if target.thread.starter != user:
+    if category_acl['can_change_marked_answers'] == 1:
+        if target.starter != user:
             raise PermissionDenied(
                 _(
                     "You don't have permission to unmark this best answer because you are not a "
@@ -290,19 +286,19 @@ def allow_unmark_best_answer(user, target):
                     'category': target.category,
                 }
             )
-        if target.thread.is_closed:
+        if target.is_closed:
             raise PermissionDenied(
                 _(
-                    "You can't unmark this best answer because its thread is closed and you don't "
-                    "have permission to open it."
+                    "You can't unmark this thread's best answer because it's closed and you "
+                    "don't have permission to open it."
                 )
             )
 
-    if target.is_protected and not category_acl['can_protect_posts']:
+    if target.best_answer_is_protected and not category_acl['can_protect_posts']:
         raise PermissionDenied(
             _(
-                "You don't have permission to unmark this best answer because a moderator has "
-                "protected it."
+                "You don't have permission to unmark this thread's best answer because a "
+                "moderator has protected it."
             )
         )
 
@@ -310,6 +306,57 @@ def allow_unmark_best_answer(user, target):
 can_unmark_best_answer = return_boolean(allow_unmark_best_answer)
 
 
+def allow_mark_as_best_answer(user, target):
+    if user.is_anonymous:
+        raise PermissionDenied(_("You have to sign in to mark best answers."))
+
+    if target.is_event:
+        raise PermissionDenied(_("Events can't be marked as best answers."))
+
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_mark_best_answers': 0,
+        }
+    )
+
+    if not category_acl['can_mark_best_answers']:
+        raise PermissionDenied(
+            _(
+                'You don\'t have permission to mark best answers in the "%(category)s" category.'
+            ) % {
+                'category': target.category,
+            }
+        )
+
+    if category_acl['can_mark_best_answers'] == 1 and target.thread.starter != user:
+        raise PermissionDenied(
+            _(
+                "You don't have permission to mark best answer in this thread because you "
+                "didn't start it."
+            )
+        )
+
+    if target.is_first_post:
+        raise PermissionDenied(_("First post in a thread can't be marked as best answer."))
+
+    if target.is_hidden:
+        raise PermissionDenied(_("Hidden posts can't be marked as best answers."))
+
+    if target.is_unapproved:
+        raise PermissionDenied(_("Unapproved posts can't be marked as best answers."))
+        
+    if target.is_protected and not category_acl['can_protect_posts']:
+        raise PermissionDenied(
+            _(
+                "You don't have permission to mark this post as best answer because a moderator "
+                "has protected it."
+            )
+        )
+
+
+can_mark_as_best_answer = return_boolean(allow_mark_as_best_answer)
+
+
 def allow_hide_best_answer(user, target):
     if target.is_best_answer:
         raise PermissionDenied(
@@ -335,7 +382,7 @@ def has_time_to_change_answer(user, target):
     change_time = category_acl.get('best_answer_change_time', 0)
 
     if change_time:
-        diff = timezone.now() - target.thread.best_answer_set_on
+        diff = timezone.now() - target.best_answer_set_on
         diff_minutes = int(diff.total_seconds() / 60)
         return diff_minutes < change_time
     else:

+ 8 - 0
misago/threads/serializers/thread.py

@@ -31,6 +31,8 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
     is_read = serializers.SerializerMethodField()
     path = BasicCategorySerializer(many=True, read_only=True)
     poll = PollSerializer(many=False, read_only=True)
+    best_answer = serializers.PrimaryKeyRelatedField(read_only=True)
+    best_answer_marked_by = serializers.PrimaryKeyRelatedField(read_only=True)
     subscription = serializers.SerializerMethodField()
 
     api = serializers.SerializerMethodField()
@@ -54,6 +56,12 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
             'is_hidden',
             'is_closed',
             'weight',
+            'best_answer',
+            'best_answer_is_protected',
+            'best_answer_marked_on',
+            'best_answer_marked_by',
+            'best_answer_marked_by_name',
+            'best_answer_marked_by_slug',
             'acl',
             'is_new',
             'is_read',

+ 5 - 5
misago/threads/tests/test_privatethread_patch_api.py

@@ -272,12 +272,12 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
                 {
                     'op': 'remove',
                     'path': 'participants',
-                    'value': 'string',
+                    'value': '',
                 },
             ]
         )
 
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertContains(response, "A valid integer is required.", status_code=400)
 
     def test_remove_invalid(self):
         """api validates user id type"""
@@ -293,7 +293,7 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
             ]
         )
 
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertContains(response, "A valid integer is required.", status_code=400)
 
     def test_remove_nonexistant(self):
         """removed user has to be participant"""
@@ -578,7 +578,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
             ]
         )
 
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertContains(response, "A valid integer is required.", status_code=400)
 
     def test_invalid_user_id(self):
         """api handles invalid user id"""
@@ -594,7 +594,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
             ]
         )
 
-        self.assertContains(response, "Participant doesn't exist.", status_code=400)
+        self.assertContains(response, "A valid integer is required.", status_code=400)
 
     def test_nonexistant_user_id(self):
         """api handles nonexistant user id"""

+ 452 - 0
misago/threads/tests/test_thread_patch_api.py

@@ -6,6 +6,7 @@ from django.utils import six, timezone
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.readtracker import poststracker
+from misago.threads import testutils
 from misago.threads.models import Thread
 
 from .test_threads_api import ThreadsApiTestCase
@@ -1401,3 +1402,454 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
         )
 
         self.assertEqual(response.status_code, 404)
+
+
+class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
+    def test_mark_best_answer(self):
+        """api makes it possible to mark best answer"""
+        self.override_acl({'can_mark_best_answers': 2})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ['ok'],
+
+            'best_answer': best_answer.id,
+            'best_answer_is_protected': False,
+            'best_answer_marked_on': response.json()['best_answer_marked_on'],
+            'best_answer_marked_by': self.user.id,
+            'best_answer_marked_by_name': self.user.username,
+            'best_answer_marked_by_slug': self.user.slug,
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertEqual(thread_json['best_answer'], best_answer.id)
+        self.assertEqual(thread_json['best_answer_is_protected'], False)
+        self.assertEqual(
+            thread_json['best_answer_marked_on'], response.json()['best_answer_marked_on'])
+        self.assertEqual(thread_json['best_answer_marked_by'], self.user.id)
+        self.assertEqual(thread_json['best_answer_marked_by_name'], self.user.username)
+        self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
+
+    def test_mark_best_answer_anonymous(self):
+        """api validates that user is authenticated before marking best answer"""
+        self.logout_user()
+
+        self.override_acl({'can_mark_best_answers': 2})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.json(), {
+            'detail': "This action is not available to guests.",
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_no_permission(self):
+        """api validates permission to mark best answers"""
+        self.override_acl({'can_mark_best_answers': 0})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                'You don\'t have permission to mark best answers in the "First category" category.'
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_not_thread_starter(self):
+        """api validates permission to mark best answers in owned thread"""
+        self.override_acl({'can_mark_best_answers': 1})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to mark best answer in this thread because you didn't "
+                "start it."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+        # passing scenario is possible
+        self.thread.starter = self.user
+        self.thread.save()
+
+        self.override_acl({'can_mark_best_answers': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_mark_best_answer_category_closed(self):
+        """api validates permission to mark best answers in closed category"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 0})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                'You don\'t have permission to mark best answer in this thread because its '
+                'category "First category" is closed.'
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+        # passing scenario is possible
+        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_mark_best_answer_thread_closed(self):
+        """api validates permission to mark best answers in closed thread"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 0})
+
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You can't mark best answer in this thread because it's closed and you don't have "
+                "permission to open it."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+        # passing scenario is possible
+        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_mark_best_answer_invalid_post_id(self):
+        """api validates that post id is int"""
+        self.override_acl({'can_mark_best_answers': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': 'd7sd89a7d98sa',
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["A valid integer is required."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_post_not_found(self):
+        """api validates that post exists"""
+        self.override_acl({'can_mark_best_answers': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': self.thread.last_post_id + 1,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["NOT FOUND"],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+        
+    def test_mark_best_answer_post_invisible(self):
+        """api validates post visibility to action author"""
+        self.override_acl({'can_mark_best_answers': 2})
+
+        unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': unapproved_post.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["NOT FOUND"],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_post_other_thread(self):
+        """api validates post belongs to same thread"""
+        self.override_acl({'can_mark_best_answers': 2})
+
+        other_thread = testutils.post_thread(self.category)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': other_thread.first_post_id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["NOT FOUND"],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_event_id(self):
+        """api validates that post is not an event"""
+        self.override_acl({'can_mark_best_answers': 2})
+
+        best_answer = testutils.reply_thread(self.thread)
+        best_answer.is_event = True
+        best_answer.save()
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["Events can't be marked as best answers."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_first_post(self):
+        """api validates that post is not a first post in thread"""
+        self.override_acl({'can_mark_best_answers': 2})
+        
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': self.thread.first_post_id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["First post in a thread can't be marked as best answer."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_hidden_post(self):
+        """api validates that post is not hidden"""
+        self.override_acl({'can_mark_best_answers': 2})
+        
+        best_answer = testutils.reply_thread(self.thread, is_hidden=True)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["Hidden posts can't be marked as best answers."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_unapproved_post(self):
+        """api validates that post is not unapproved"""
+        self.override_acl({'can_mark_best_answers': 2})
+        
+        best_answer = testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': ["Unapproved posts can't be marked as best answers."],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+    def test_mark_best_answer_protected_post(self):
+        """api respects post protection"""
+        self.override_acl({'can_mark_best_answers': 2, 'can_protect_posts': 0})
+        
+        best_answer = testutils.reply_thread(self.thread, is_protected=True)
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'id': self.thread.id,
+            'detail': [
+                "You don't have permission to mark this post as best answer because a moderator "
+                "has protected it."
+            ],
+        })
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json['best_answer'])
+
+        # passing scenario is possible
+        self.override_acl({'can_mark_best_answers': 2, 'can_protect_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'best-answer',
+                    'value': best_answer.id,
+                },
+            ]
+        )
+        self.assertEqual(response.status_code, 200)