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

improv tests coverage for thread/1321/posts/ endpoint, tiny style fixes, tests for reply/edit editor endpoints

Rafał Pitoń 8 лет назад
Родитель
Сommit
f24bcb2167

+ 55 - 4
misago/threads/api/threadposts.py

@@ -1,8 +1,14 @@
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext as _
+
 from rest_framework import viewsets
+from rest_framework.decorators import detail_route, list_route
 from rest_framework.response import Response
 
 from misago.core.shortcuts import get_int_or_404
 
+from ..permissions.threads import allow_edit_post, allow_reply_thread
+from ..viewmodels.post import ThreadPost
 from ..viewmodels.posts import ThreadPosts
 from ..viewmodels.thread import ForumThread
 
@@ -11,6 +17,22 @@ class ViewSet(viewsets.ViewSet):
     thread = None
     posts = None
 
+    def get_thread(self, request, pk):
+        return self.thread(request, get_int_or_404(pk), read_aware=True, subscription_aware=True)
+
+    def get_posts(self, request, thread, page):
+        return self.posts(request, thread, page)
+
+    def create(self, request, thread_pk):
+        thread = self.thread(request, get_int_or_404(thread_pk))
+        allow_reply_thread(request.user, thread.thread)
+
+    def update(self, request, thread_pk, pk):
+        thread = self.thread(request, get_int_or_404(thread_pk))
+        post = ThreadPost(request, thread, get_int_or_404(pk)).post
+
+        allow_edit_post(request.user, post)
+
     def list(self, request, thread_pk):
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
@@ -24,11 +46,40 @@ class ViewSet(viewsets.ViewSet):
 
         return Response(data)
 
-    def get_thread(self, request, pk):
-        return self.thread(request, get_int_or_404(pk), read_aware=True, subscription_aware=True)
+    @detail_route(methods=['get'], url_path='editor')
+    def post_editor(self, request, thread_pk, pk):
+        thread = self.thread(request, get_int_or_404(thread_pk))
+        post = ThreadPost(request, thread, get_int_or_404(pk)).post
 
-    def get_posts(self, request, thread, page):
-        return self.posts(request, thread, page)
+        allow_edit_post(request.user, post)
+
+        return Response({
+            'id': post.pk,
+            'api': post.get_api_url(),
+            'post': post.original,
+            'can_protect': bool(thread.category.acl['can_protect_posts']),
+            'is_protected': post.is_protected,
+            'poster': post.poster_name
+        })
+
+    @list_route(methods=['get'], url_path='editor')
+    def reply_editor(self, request, thread_pk):
+        thread = self.thread(request, get_int_or_404(thread_pk))
+        allow_reply_thread(request.user, thread.thread)
+
+        if 'reply' in request.query_params:
+            reply_to = ThreadPost(request, thread, get_int_or_404(request.query_params['reply'])).post
+
+            if reply_to.is_hidden and not reply_to.acl['can_see_hidden']:
+                raise PermissionDenied(_("You can't reply to hidden posts"))
+
+            return Response({
+                'id': reply_to.pk,
+                'post': reply_to.original,
+                'poster': reply_to.poster_name
+            })
+        else:
+            return Response({})
 
 
 class ThreadPostsViewSet(ViewSet):

+ 1 - 1
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 detail_route, list_route
+from rest_framework.decorators import list_route
 from rest_framework.response import Response
 
 from misago.acl import add_acl

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

@@ -114,6 +114,9 @@ class Post(models.Model):
     def get_absolute_url(self):
         return self.thread_type.get_post_absolute_url(self)
 
+    def get_api_url(self):
+        return self.thread_type.get_post_absolute_url(self)
+
     @property
     def short(self):
         if self.is_valid:

+ 13 - 13
misago/threads/permissions/threads.py

@@ -423,6 +423,13 @@ def add_acl_to_thread(user, thread):
         thread.acl['can_merge'] = category_acl.get('can_merge_threads', False)
 
 
+def add_acl_to_post(user, post):
+    if post.is_event:
+        add_acl_to_event(user, post)
+    else:
+        add_acl_to_reply(user, post)
+
+
 def add_acl_to_event(user, event):
     category_acl = user.acl['categories'].get(event.category_id, {})
     can_hide_events = category_acl.get('can_hide_events', 0)
@@ -456,13 +463,6 @@ def add_acl_to_reply(user, post):
         post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
 
 
-def add_acl_to_post(user, post):
-    if post.is_event:
-        add_acl_to_event(user, post)
-    else:
-        add_acl_to_reply(user, post)
-
-
 def register_with(registry):
     registry.acl_annotator(Category, add_acl_to_category)
     registry.acl_annotator(Thread, add_acl_to_thread)
@@ -567,6 +567,12 @@ def allow_edit_post(user, target):
     if not category_acl['can_edit_posts']:
         raise PermissionDenied(_("You can't edit posts in this category."))
 
+    if not category_acl['can_close_threads']:
+        if target.category.is_closed:
+            raise PermissionDenied(_("This category is closed. You can't edit posts in it."))
+        if target.thread.is_closed:
+            raise PermissionDenied(_("This thread is closed. You can't edit posts in it."))
+
     if target.is_hidden and not can_unhide_post(user, target):
         raise PermissionDenied(_("This post is hidden, you can't edit it."))
 
@@ -574,12 +580,6 @@ def allow_edit_post(user, target):
         if target.poster_id != user.pk:
             raise PermissionDenied(_("You can't edit other users posts in this category."))
 
-        if not category_acl['can_close_threads']:
-            if target.category.is_closed:
-                raise PermissionDenied(_("This category is closed. You can't edit posts in it."))
-            if target.thread.is_closed:
-                raise PermissionDenied(_("This thread is closed. You can't edit posts in it."))
-
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't edit it."))
 

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

@@ -88,6 +88,9 @@ class ThreadSerializer(serializers.ModelSerializer):
             'posts': reverse('misago:api:thread-post-list', kwargs={
                 'thread_pk': obj.pk
             }),
+            'editor': reverse('misago:api:thread-post-editor', kwargs={
+                'thread_pk': obj.pk
+            }),
             'read': 'nada',
         }
 

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

@@ -24,7 +24,8 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         self.api_link = self.thread.get_api_url()
 
     def override_acl(self, acl=None):
-        final_acl = {
+        final_acl = self.user.acl['categories'][self.category.pk]
+        final_acl.update({
             'can_see': 1,
             'can_browse': 1,
             'can_see_all_threads': 1,
@@ -34,7 +35,7 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
             'can_edit_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
-        }
+        })
 
         if acl:
             final_acl.update(acl)
@@ -116,6 +117,43 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
 
+    def test_api_validates_posts_visibility(self):
+        """api endpoint validates posts visiblity"""
+        self.override_acl({
+            'can_hide_posts': 0
+        })
+
+        hidden_post = testutils.reply_thread(self.thread, is_hidden=True, message="I'am hidden test message!")
+
+        response = self.client.get(self.tested_links[1])
+        self.assertNotContains(response, hidden_post.parsed) # post's body is hidden
+
+        # add permission to see hidden posts
+        self.override_acl({
+            'can_hide_posts': 1
+        })
+
+        response = self.client.get(self.tested_links[1])
+        self.assertContains(response, hidden_post.parsed) # hidden post's body is visible with permission
+
+        self.override_acl({
+            'can_approve_content': 0
+        })
+
+        # unapproved posts shouldn't show at all
+        unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
+
+        response = self.client.get(self.tested_links[1])
+        self.assertNotContains(response, unapproved_post.get_absolute_url())
+
+        # add permission to see unapproved posts
+        self.override_acl({
+            'can_approve_content': 1
+        })
+
+        response = self.client.get(self.tested_links[1])
+        self.assertContains(response, unapproved_post.get_absolute_url())
+
 
 class ThreadDeleteApiTests(ThreadsApiTestCase):
     def test_delete_thread(self):

+ 347 - 6
misago/threads/tests/test_threads_editor_api.py

@@ -7,16 +7,18 @@ from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.users.testutils import AuthenticatedUserTestCase
 
+from .. import testutils
 
-class ThreadsEditorApiTestCase(AuthenticatedUserTestCase):
+
+class EditorApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
-        super(ThreadsEditorApiTestCase, self).setUp()
+        super(EditorApiTestCase, self).setUp()
 
         self.category = Category.objects.get(slug='first-category')
-        self.api_link = reverse('misago:api:thread-editor')
 
     def override_acl(self, acl=None):
-        final_acl = {
+        final_acl = self.user.acl['categories'][self.category.pk]
+        final_acl.update({
             'can_see': 1,
             'can_browse': 1,
             'can_see_all_threads': 1,
@@ -44,7 +46,7 @@ class ThreadsEditorApiTestCase(AuthenticatedUserTestCase):
             'can_see_posts_likes': 0,
             'can_like_posts': 0,
             'can_hide_events': 0,
-        }
+        })
 
         if acl:
             final_acl.update(acl)
@@ -60,12 +62,18 @@ class ThreadsEditorApiTestCase(AuthenticatedUserTestCase):
             }
         })
 
+
+class ThreadPostEditorApiTests(EditorApiTestCase):
+    def setUp(self):
+        super(ThreadPostEditorApiTests, self).setUp()
+
+        self.api_link = reverse('misago:api:thread-editor')
+
     def test_anonymous_user_request(self):
         """endpoint validates if user is authenticated"""
         self.logout_user()
 
         response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
         self.assertContains(response, "You need to be signed in", status_code=403)
 
     def test_category_visibility_validation(self):
@@ -250,3 +258,336 @@ class ThreadsEditorApiTestCase(AuthenticatedUserTestCase):
                 'pin': 0
             }
         })
+
+
+class ThreadReplyEditorApiTests(EditorApiTestCase):
+    def setUp(self):
+        super(ThreadReplyEditorApiTests, self).setUp()
+
+        self.thread = testutils.post_thread(category=self.category)
+        self.api_link = reverse('misago:api:thread-post-editor', kwargs={
+            'thread_pk': self.thread.pk
+        })
+
+    def test_anonymous_user_request(self):
+        """endpoint validates if user is authenticated"""
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "You have to sign in to reply threads.", status_code=403)
+
+    def test_thread_visibility(self):
+        """thread's visibility is validated"""
+        self.override_acl({'can_see': 0})
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+        self.override_acl({'can_browse': 0})
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+        self.override_acl({'can_see_all_threads': 0})
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_no_reply_permission(self):
+        """permssion to reply is validated"""
+        self.override_acl({
+            'can_reply_threads': 0
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "You can't reply to threads in this category.", status_code=403)
+
+    def test_closed_category(self):
+        """permssion to reply in closed category is validated"""
+        self.override_acl({
+            'can_reply_threads': 1,
+            'can_close_threads': 0
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "This category is closed. You can't reply to threads in it.", status_code=403)
+
+        # allow to post in closed category
+        self.override_acl({
+            'can_reply_threads': 1,
+            'can_close_threads': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_closed_thread(self):
+        """permssion to reply in closed thread is validated"""
+        self.override_acl({
+            'can_reply_threads': 1,
+            'can_close_threads': 0
+        })
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "You can't reply to closed threads in this category.", status_code=403)
+
+        # allow to post in closed thread
+        self.override_acl({
+            'can_reply_threads': 1,
+            'can_close_threads': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_allow_reply_thread(self):
+        """api returns 200 code if thread reply is allowed"""
+        self.override_acl({
+            'can_reply_threads': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_reply_to_visibility(self):
+        """api validates replied post visibility"""
+        self.override_acl({
+            'can_reply_threads': 1
+        })
+
+        # unapproved reply can't be replied to
+        unapproved_reply = testutils.reply_thread(self.thread, is_unapproved=True)
+
+        response = self.client.get('{}?reply={}'.format(self.api_link, unapproved_reply.pk))
+        self.assertEqual(response.status_code, 404)
+
+        # hidden reply can't be replied to
+        self.override_acl({
+            'can_reply_threads': 1
+        })
+
+        hidden_reply = testutils.reply_thread(self.thread, is_hidden=True)
+
+        response = self.client.get('{}?reply={}'.format(self.api_link, hidden_reply.pk))
+        self.assertContains(response, "You can't reply to hidden posts", status_code=403)
+
+    def test_reply_to_other_thread_post(self):
+        """api validates is replied post belongs to same thread"""
+        other_thread = testutils.post_thread(category=self.category)
+        reply_to = testutils.reply_thread(other_thread)
+
+        response = self.client.get('{}?reply={}'.format(self.api_link, reply_to.pk))
+        self.assertEqual(response.status_code, 404)
+
+    def test_reply_to(self):
+        """api includes replied to post details in response"""
+        self.override_acl({
+            'can_reply_threads': 1
+        })
+
+        reply_to = testutils.reply_thread(self.thread)
+
+        response = self.client.get('{}?reply={}'.format(self.api_link, reply_to.pk))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(json.loads(smart_str(response.content)), {
+            'id': reply_to.pk,
+            'post': reply_to.original,
+            'poster': reply_to.poster_name
+        })
+
+
+class EditReplyEditorApiTests(EditorApiTestCase):
+    def setUp(self):
+        super(EditReplyEditorApiTests, self).setUp()
+
+        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-editor', kwargs={
+            'thread_pk': self.thread.pk,
+            'pk': self.post.pk
+        })
+
+    def test_anonymous_user_request(self):
+        """endpoint validates if user is authenticated"""
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "You have to sign in to edit posts.", status_code=403)
+
+    def test_thread_visibility(self):
+        """thread's visibility is validated"""
+        self.override_acl({'can_see': 0})
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+        self.override_acl({'can_browse': 0})
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+        self.override_acl({'can_see_all_threads': 0})
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_no_edit_permission(self):
+        """permssion to edit is validated"""
+        self.override_acl({
+            'can_edit_posts': 0
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "You can't edit posts in this category.", status_code=403)
+
+    def test_closed_category(self):
+        """permssion to edit in closed category is validated"""
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_close_threads': 0
+        })
+
+        self.category.is_closed = True
+        self.category.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "This category is closed. You can't edit posts in it.", status_code=403)
+
+        # allow to edit in closed category
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_close_threads': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_closed_thread(self):
+        """permssion to edit in closed thread is validated"""
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_close_threads': 0
+        })
+
+        self.thread.is_closed = True
+        self.thread.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "This thread is closed. You can't edit posts in it.", status_code=403)
+
+        # allow to edit in closed thread
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_close_threads': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_protected_post(self):
+        """permssion to edit protected post is validated"""
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_protect_posts': 0
+        })
+
+        self.post.is_protected = True
+        self.post.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "This post is protected. You can't edit it.", status_code=403)
+
+        # allow to post in closed thread
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_protect_posts': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_post_visibility(self):
+        """edited posts visibility is validated"""
+        self.override_acl({
+            'can_edit_posts': 1
+        })
+
+        self.post.is_hidden = True;
+        self.post.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "This post is hidden, you can't edit it.", status_code=403)
+
+        # allow hidden edition
+        self.override_acl({
+            'can_edit_posts': 1,
+            'can_hide_posts': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+        # test unapproved post
+        self.post.is_hidden = False;
+        self.post.poster = None;
+        self.post.save()
+
+        self.override_acl({
+            'can_edit_posts': 2,
+            'can_approve_content': 0
+        })
+
+        self.post.is_unapproved = True;
+        self.post.save()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 404)
+
+        # allow unapproved edition
+        self.override_acl({
+            'can_edit_posts': 2,
+            'can_approve_content': 1
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_other_user_post(self):
+        """api validates if other user's post can be edited"""
+        self.override_acl({
+            'can_edit_posts': 1,
+        })
+
+        self.post.poster = None;
+        self.post.save()
+
+        response = self.client.get(self.api_link)
+        self.assertContains(response, "You can't edit other users posts in this category.", status_code=403)
+
+        # allow other users post edition
+        self.override_acl({
+            'can_edit_posts': 2,
+        })
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+
+    def test_edit(self):
+        """endpoint returns valid configuration for editor"""
+        self.override_acl({
+            'can_edit_posts': 1,
+        })
+
+        response = self.client.get(self.api_link)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(json.loads(smart_str(response.content)), {
+            'id': self.post.pk,
+            'api': self.post.get_api_url(),
+            'post': self.post.original,
+            'can_protect': False,
+            'is_protected': self.post.is_protected,
+            'poster': self.post.poster_name
+        })

+ 1 - 1
misago/threads/testutils.py

@@ -58,7 +58,7 @@ def post_thread(category, title='Test thread', poster='Tester',
     return thread
 
 
-def reply_thread(thread, poster="Tester", message='I am test message',
+def reply_thread(thread, poster="Tester", message="I am test message",
                  is_unapproved=False, is_hidden=False, has_reports=False,
                  has_open_reports=False, posted_on=None, poster_ip='127.0.0.1'):
     posted_on = posted_on or thread.last_post_on + timedelta(minutes=5)

+ 3 - 0
misago/threads/threadtypes/thread.py

@@ -80,3 +80,6 @@ class Thread(ThreadType):
                 'pk': post.thread.pk,
                 'post': post.pk
             })
+
+    def get_post_api_url(self, post):
+        return reverse('misago:api:threads-post-detail', kwargs={'thread_ok': post.thread_id, 'pk': post.pk})

+ 36 - 0
misago/threads/viewmodels/post.py

@@ -0,0 +1,36 @@
+from django.shortcuts import get_object_or_404
+
+from misago.acl import add_acl
+
+from ..permissions.threads import allow_see_post, exclude_invisible_posts
+
+
+class ViewModel(object):
+    def __init__(self, request, thread, pk):
+        post = self.get_post(request, thread, pk)
+
+        add_acl(request.user, post)
+
+        self.post = post
+
+    def get_post(self, request, thread, pk):
+        queryset = self.get_queryset(request, thread.thread)
+        post = get_object_or_404(queryset, pk=pk, is_event=False)
+
+        post.category = thread.category
+
+        allow_see_post(request.user, post)
+
+        return post
+
+    def get_queryset(self, request, thread):
+        queryset = thread.post_set.select_related(
+            'poster',
+            'poster__rank',
+            'poster__ban_cache'
+        )
+        return exclude_invisible_posts(request.user, thread.category, queryset)
+
+
+class ThreadPost(ViewModel):
+    pass