Browse Source

getting started on threads/x/posts/ api endpoint

Rafał Pitoń 9 years ago
parent
commit
0ba4264220

+ 4 - 5
misago/conf/defaults.py

@@ -311,13 +311,12 @@ CRISPY_TEMPLATE_PACK = 'bootstrap3'
 
 # Rest Framework Configuration
 REST_FRAMEWORK = {
-    'UNAUTHENTICATED_USER': 'misago.users.models.AnonymousUser',
-
-    'EXCEPTION_HANDLER': 'misago.core.exceptionhandler.handle_api_exception',
-
     'DEFAULT_PERMISSION_CLASSES': (
         'misago.users.rest_permissions.IsAuthenticatedOrReadOnly',
-    )
+    ),
+    'EXCEPTION_HANDLER': 'misago.core.exceptionhandler.handle_api_exception',
+    'UNAUTHENTICATED_USER': 'misago.users.models.AnonymousUser',
+    'URL_FORMAT_OVERRIDE': None,
 }
 
 

+ 3 - 0
misago/core/apirouter.py

@@ -3,6 +3,9 @@ from rest_framework.routers import (
 
 
 class MisagoApiRouter(DefaultRouter):
+    include_root_view = False
+    include_format_suffixes = False
+
     routes = [
         # List route.
         Route(

+ 5 - 7
misago/threads/api/threadendpoints/list.py

@@ -1,6 +1,7 @@
-from django.http import Http404
 from rest_framework.response import Response
 
+from misago.core.shortcuts import get_int_or_404
+
 from misago.threads.viewmodels.category import ThreadsRootCategory, ThreadsCategory
 from misago.threads.viewmodels.threads import ForumThreads
 
@@ -12,12 +13,9 @@ class ListEndpointBase(object):
     template_name = None
 
     def __call__(self, request, **kwargs):
-        try:
-            page = int(request.query_params.get('page', 0))
-            if page == 1:
-                page = 0 # api allows explicit first page
-        except (ValueError, TypeError):
-            raise Http404()
+        page = get_int_or_404(request.query_params.get('page', 0))
+        if page == 1:
+            page = 0 # api allows explicit first page
 
         list_type = request.query_params.get('list', 'all')
 

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

@@ -0,0 +1,36 @@
+from rest_framework import viewsets
+from rest_framework.response import Response
+
+from misago.core.shortcuts import get_int_or_404
+
+from misago.threads.viewmodels.posts import ThreadPosts
+from misago.threads.viewmodels.thread import ForumThread
+
+
+class ViewSet(viewsets.ViewSet):
+    thread = None
+    posts = None
+
+    def get_thread(self, request, pk):
+        return self.thread(request, get_int_or_404(pk))
+
+    def get_posts(self, request, thread, page):
+        return self.posts(request, thread, page)
+
+    def list(self, request, thread_pk):
+        page = get_int_or_404(request.query_params.get('page', 0))
+        if page == 1:
+            page = 0 # api allows explicit first page
+
+        thread = self.get_thread(request, thread_pk)
+        posts = self.get_posts(request, thread, page)
+
+        data = thread.get_frontend_context()
+        data['post_set'] = posts.get_frontend_context()
+
+        return Response(data)
+
+
+class ThreadPostsViewSet(ViewSet):
+    thread = ForumThread
+    posts = ThreadPosts

+ 22 - 42
misago/threads/api/threads.py

@@ -4,74 +4,49 @@ from django.utils.translation import gettext as _
 
 from rest_framework import viewsets
 from rest_framework.decorators import detail_route, list_route
-from rest_framework.parsers import JSONParser
 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 (
-    allow_see_category, allow_browse_category)
+from misago.categories.permissions import allow_see_category, allow_browse_category
 from misago.core.shortcuts import get_int_or_404, get_object_or_404
 from misago.readtracker.categoriestracker import read_category
-from misago.readtracker.threadstracker import make_read_aware
-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.models import Subscription
 from misago.threads.moderation import threads as moderation
-from misago.threads.permissions.threads import allow_see_thread
-from misago.threads.serializers import ThreadSerializer
 from misago.threads.subscriptions import make_subscription_aware
+from misago.threads.viewmodels.thread import ForumThread
 
 
-class ThreadViewSet(viewsets.ViewSet):
-    permission_classes = (IsAuthenticatedOrReadOnly, )
-    parser_classes=(JSONParser, )
+class ViewSet(viewsets.ViewSet):
+    thread = None
+    TREE_ID = None
 
-    TREE_ID = CATEGORIES_TREE_ID
-
-    def validate_thread_visible(self, user, thread):
-        allow_see_thread(user, thread)
-
-    def get_thread(self, user, thread_id):
-        thread = get_object_or_404(Thread.objects.select_related('category'),
-            id=get_int_or_404(thread_id),
-            category__tree_id=self.TREE_ID,
-        )
-
-        add_acl(user, thread.category)
-        add_acl(user, thread)
-
-        self.validate_thread_visible(user, thread)
-
-        return thread
+    def get_thread(self, request, pk):
+        return self.thread(request, get_int_or_404(pk))
 
     def list(self, request):
         return threads_list_endpoint(request)
 
-    def retrieve(self, request, pk=None):
-        thread = self.get_thread(request.user, pk)
+    def retrieve(self, request, pk):
+        thread = self.get_thread(request, pk)
+        return Response(thread.get_frontend_context())
 
-        make_read_aware(request.user, thread)
-        make_subscription_aware(request.user, thread)
+    def partial_update(self, request, pk):
+        thread = self.get_thread(request, pk)
+        return thread_patch_endpoint.dispatch(request, thread.thread)
 
-        return Response(ThreadSerializer(thread).data)
-
-    def partial_update(self, request, pk=None):
-        thread = self.get_thread(request.user, pk)
-        return thread_patch_endpoint.dispatch(request, thread)
-
-    def destroy(self, request, pk=None):
-        thread = self.get_thread(request.user, pk)
+    def destroy(self, request, pk):
+        thread = self.get_thread(request, pk).thread
 
         if thread.acl.get('can_hide') == 2:
             moderation.delete_thread(request.user, thread)
             return Response({'detail': 'ok'})
         else:
-            raise PermissionDenied(
-                _("You don't have permission to delete this thread."))
+            raise PermissionDenied(_("You don't have permission to delete this thread."))
 
     @list_route(methods=['post'])
     def read(self, request):
@@ -90,6 +65,11 @@ class ThreadViewSet(viewsets.ViewSet):
         read_category(request.user, category)
         return Response({'detail': 'ok'})
 
+
+class ThreadViewSet(ViewSet):
+    thread = ForumThread
+    TREE_ID = CATEGORIES_TREE_ID
+
     @list_route(methods=['post'])
     def merge(self, request):
         return threads_merge_endpoint(request)

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

@@ -31,6 +31,7 @@ class Post(models.Model):
 
     posted_on = models.DateTimeField()
     updated_on = models.DateTimeField()
+    hidden_on = models.DateTimeField(default=timezone.now)
 
     edits = models.PositiveIntegerField(default=0)
     last_editor = models.ForeignKey(
@@ -52,7 +53,6 @@ class Post(models.Model):
     )
     hidden_by_name = models.CharField(max_length=255, null=True, blank=True)
     hidden_by_slug = models.SlugField(max_length=255, null=True, blank=True)
-    hidden_on = models.DateTimeField(default=timezone.now)
 
     has_reports = models.BooleanField(default=False)
     has_open_reports = models.BooleanField(default=False)

+ 10 - 19
misago/threads/permissions/threads.py

@@ -411,10 +411,7 @@ def add_acl_to_reply(user, post):
     })
 
     if not post.acl['can_see_hidden']:
-        if user.is_authenticated() and user.id == post.poster_id:
-            post.acl['can_see_hidden'] = True
-        else:
-            post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
+        post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
 
 
 def add_acl_to_post(user, post):
@@ -459,8 +456,7 @@ def allow_start_thread(user, target):
             _("This category is closed. You can't start new threads in it."))
 
     if not user.acl['categories'].get(target.id, {'can_start_threads': False}):
-        raise PermissionDenied(_("You don't have permission to start "
-                                 "new threads in this category."))
+        raise PermissionDenied(_("You don't have permission to start new threads in this category."))
 can_start_thread = return_boolean(allow_start_thread)
 
 
@@ -472,11 +468,9 @@ def allow_reply_thread(user, target):
 
     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."))
+            raise PermissionDenied(_("This category is closed. You can't reply to threads in it."))
         if target.is_closed:
-            raise PermissionDenied(
-                _("You can't reply to closed threads in this category."))
+            raise PermissionDenied(_("You can't reply to closed threads in this category."))
 
     if not category_acl.get('can_reply_threads', False):
         raise PermissionDenied(_("You can't reply to threads in this category."))
@@ -499,18 +493,15 @@ def allow_edit_thread(user, target):
 
         if not category_acl['can_close_threads']:
             if target.category.is_closed:
-                raise PermissionDenied(
-                    _("This category is closed. You can't edit threads in it."))
+                raise PermissionDenied(_("This category is closed. You can't edit threads in it."))
             if target.is_closed:
-                raise PermissionDenied(
-                    _("You can't edit closed threads in this category."))
+                raise PermissionDenied(_("You can't edit closed threads in this category."))
 
         if not has_time_to_edit_thread(user, target):
-            message = ungettext("You can't edit threads that are "
-                                "older than %(minutes)s minute.",
-                                "You can't edit threads that are "
-                                "older than %(minutes)s minutes.",
-                                category_acl['thread_edit_time'])
+            message = ungettext(
+                "You can't edit threads that are older than %(minutes)s minute.",
+                "You can't edit threads that are older than %(minutes)s minutes.",
+                category_acl['thread_edit_time'])
             raise PermissionDenied(
                 message % {'minutes': category_acl['thread_edit_time']})
 can_edit_thread = return_boolean(allow_edit_thread)

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

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

+ 104 - 0
misago/threads/serializers/post.py

@@ -0,0 +1,104 @@
+from django.core.urlresolvers import reverse
+from rest_framework import serializers
+
+from misago.users.serializers import UserSerializer
+
+from misago.threads.models import Post
+
+
+__all__ = [
+    'PostSerializer',
+    'ThreadPostSerializer',
+]
+
+
+class PostSerializer(serializers.ModelSerializer):
+    poster = UserSerializer(many=False, read_only=True)
+    parsed = serializers.SerializerMethodField()
+    attachments_cache = serializers.SerializerMethodField()
+    last_editor = UserSerializer(many=False, read_only=True)
+    last_editor_id = serializers.SerializerMethodField()
+    last_editor_url = serializers.SerializerMethodField()
+    hidden_by = UserSerializer(many=False, read_only=True)
+    hidden_by_id = serializers.SerializerMethodField()
+    hidden_by_url = serializers.SerializerMethodField()
+    acl = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Post
+        fields = (
+            'id',
+        )
+
+    def get_parsed(self, obj):
+        if obj.is_valid and not obj.is_event and (not obj.is_hidden or obj.acl['can_see_hidden']):
+            return obj.parsed
+        else:
+            return None
+
+    def get_attachments_cache(self, obj):
+        # TODO: check if user can download attachments before we'll expose them here
+        return None
+
+    def get_last_editor_id(self, obj):
+        return obj.last_editor_id
+
+    def get_last_editor_url(self, obj):
+        if obj.last_editor_id:
+            return reverse('misago:user', kwargs={
+                'pk': obj.last_editor_id,
+                'slug': obj.last_editor_slug
+            })
+        else:
+            return None
+
+    def get_hidden_by_id(self, obj):
+        return obj.hidden_by_id
+
+    def get_hidden_by_url(self, obj):
+        if obj.hidden_by:
+            return reverse('misago:user', kwargs={
+                'pk': obj.hidden_by_id,
+                'slug': obj.hidden_by_slug
+            })
+        else:
+            return None
+
+    def get_acl(self, obj):
+        try:
+            return obj.acl
+        except AttributeError:
+            return None
+
+
+class ThreadPostSerializer(PostSerializer):
+    class Meta:
+        model = Post
+        fields = (
+            'id',
+            'poster',
+            'poster_name',
+            'poster_ip',
+            'parsed',
+            'has_attachments',
+            'attachments_cache',
+            'posted_on',
+            'updated_on',
+            'hidden_on',
+            'edits',
+            'last_editor_id',
+            'last_editor_name',
+            'last_editor_slug',
+            'last_editor_url',
+            'hidden_by_id',
+            'hidden_by_name',
+            'hidden_by_slug',
+            'hidden_by_url',
+            'is_unapproved',
+            'is_hidden',
+            'is_protected',
+            'is_event',
+            'event_type',
+            'event_context',
+            'acl',
+        )

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

@@ -13,7 +13,7 @@ __all__ = [
 
 
 class ThreadSerializer(serializers.ModelSerializer):
-    category = BasicCategorySerializer()
+    category = BasicCategorySerializer(many=False, read_only=True)
     is_read = serializers.SerializerMethodField()
     last_poster_url = serializers.SerializerMethodField()
     absolute_url = serializers.SerializerMethodField()
@@ -127,4 +127,4 @@ class ThreadListSerializer(ThreadSerializer):
         try:
             return obj.acl
         except AttributeError:
-            return {}
+            return {}

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

@@ -18,7 +18,7 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         self.thread = testutils.post_thread(category=self.category)
         self.api_link = self.thread.get_api_url()
 
-    def override_acl(self, acl):
+    def override_acl(self, acl=None):
         final_acl = {
             'can_see': 1,
             'can_browse': 1,
@@ -26,8 +26,13 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
             'can_see_own_threads': 0,
             'can_hide_threads': 0,
             'can_approve_content': 0,
+            'can_edit_posts': 0,
+            'can_hide_posts': 0,
+            'can_hide_own_posts': 0,
         }
-        final_acl.update(acl)
+
+        if acl:
+            final_acl.update(acl)
 
         override_acl(self.user, {
             'categories': {
@@ -42,6 +47,71 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         return json.loads(response.content)
 
 
+class ThreadRetrieveApiTests(ThreadsApiTestCase):
+    def setUp(self):
+        super(ThreadRetrieveApiTests, self).setUp()
+
+        self.tested_links = [
+            self.api_link,
+            '%sposts/' % self.api_link,
+            '%sposts/?page=1' % self.api_link,
+        ]
+
+    def test_api_returns_thread(self):
+        """api endpoint has no showstoppers"""
+        for link in self.tested_links:
+            self.override_acl()
+
+            response = self.client.get(link)
+            self.assertEqual(response.status_code, 200)
+
+            response_json = json.loads(response.content)
+            self.assertEqual(response_json['id'], self.thread.pk)
+            self.assertEqual(response_json['title'], self.thread.title)
+
+            if 'posts' in link:
+                self.assertIn('post_set', response_json)
+
+    def test_api_shows_owner_thread(self):
+        """api handles "owned threads only"""
+        for link in self.tested_links:
+            self.override_acl({
+                'can_see_all_threads': 0
+            })
+
+            response = self.client.get(link)
+            self.assertEqual(response.status_code, 404)
+
+        self.thread.starter = self.user
+        self.thread.save()
+
+        for link in self.tested_links:
+            self.override_acl({
+                'can_see_all_threads': 0
+            })
+
+            response = self.client.get(link)
+            self.assertEqual(response.status_code, 200)
+
+    def test_api_validates_category_permissions(self):
+        """api endpoint validates category visiblity"""
+        for link in self.tested_links:
+            self.override_acl({
+                'can_see': 0
+            })
+
+            response = self.client.get(link)
+            self.assertEqual(response.status_code, 404)
+
+        for link in self.tested_links:
+            self.override_acl({
+                'can_browse': 0
+            })
+
+            response = self.client.get(link)
+            self.assertEqual(response.status_code, 404)
+
+
 class ThreadDeleteApiTests(ThreadsApiTestCase):
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""

+ 0 - 0
misago/threads/tests/test_threadview.py


+ 3 - 1
misago/threads/urls/api.py

@@ -1,7 +1,9 @@
 from misago.core.apirouter import MisagoApiRouter
+from misago.threads.api.threadposts import ThreadPostsViewSet
 from misago.threads.api.threads import ThreadViewSet
 
 
 router = MisagoApiRouter()
 router.register(r'threads', ThreadViewSet, base_name='thread')
-urlpatterns = router.urls
+router.register(r'threads/(?P<thread_pk>[^/.]+)/posts', ThreadPostsViewSet, base_name='thread-post')
+urlpatterns = router.urls

+ 8 - 1
misago/threads/viewmodels/posts.py

@@ -6,6 +6,7 @@ from misago.readtracker.threadstracker import make_posts_read_aware
 from misago.users.online.utils import make_users_status_aware
 
 from misago.threads.permissions.threads import exclude_invisible_posts
+from misago.threads.serializers import ThreadPostSerializer
 
 
 class ViewModel(object):
@@ -43,7 +44,13 @@ class ViewModel(object):
         return exclude_invisible_posts(request.user, thread.category, queryset)
 
     def get_frontend_context(self):
-        return {}
+        context = {
+            'results': ThreadPostSerializer(self.posts, many=True).data
+        }
+
+        context.update(self.paginator)
+
+        return context
 
     def get_template_context(self):
         return {

+ 2 - 4
misago/threads/viewmodels/thread.py

@@ -17,7 +17,7 @@ BASE_QUERYSET = Thread.objects.select_related(
 
 
 class ViewModel(object):
-    def __init__(self, request, slug, pk):
+    def __init__(self, request, pk, slug=None):
         thread = self.get_thread(request, pk, slug)
 
         thread.path = self.get_thread_path(thread.category)
@@ -55,9 +55,7 @@ class ViewModel(object):
         raise NotImplementedError('Thread view model has to implement get_root_name()')
 
     def get_frontend_context(self):
-        return {
-            'THREAD': ThreadSerializer(self.thread).data
-        }
+        return ThreadSerializer(self.thread).data
 
     def get_template_context(self):
         return {

+ 6 - 5
misago/threads/views/thread.py

@@ -13,7 +13,7 @@ class ThreadBase(View):
     template_name = None
 
     def get(self, request, pk, slug, page=0):
-        thread = self.get_thread(request, slug, pk)
+        thread = self.get_thread(request, pk, slug)
         posts = self.get_posts(request, thread, page)
 
         frontend_context = self.get_frontend_context(request, thread, posts)
@@ -34,8 +34,10 @@ class ThreadBase(View):
     def get_frontend_context(self, request, thread, posts):
         context = self.get_default_frontend_context()
 
-        context.update(thread.get_frontend_context())
-        context.update(posts.get_frontend_context())
+        context.update({
+            'THREAD': thread.get_frontend_context(),
+            'POSTS': posts.get_frontend_context(),
+        })
 
         return context
 
@@ -56,8 +58,7 @@ class Thread(ThreadBase):
 
     def get_default_frontend_context(self):
         return {
-            'THREADS_API': reverse('misago:api:thread-list'),
-            #'POSTS_API': reverse('misago:api:posts-list'),
+            'THREADS_API': reverse('misago:api:thread-list')
         }
 
 

+ 3 - 7
misago/users/api/auth.py

@@ -11,14 +11,10 @@ from misago.conf import settings
 from misago.core.mail import mail_user
 
 from misago.users.bans import get_user_ban
-from misago.users.forms.auth import (
-    AuthenticationForm, ResendActivationForm, ResetPasswordForm)
+from misago.users.forms.auth import AuthenticationForm, ResendActivationForm, ResetPasswordForm
 from misago.users.rest_permissions import UnbannedAnonOnly, UnbannedOnly
-from misago.users.serializers import (
-    AuthenticatedUserSerializer, AnonymousUserSerializer)
-from misago.users.tokens import (
-    make_activation_token, make_password_change_token,
-    is_password_change_token_valid)
+from misago.users.serializers import AuthenticatedUserSerializer, AnonymousUserSerializer
+from misago.users.tokens import make_activation_token, make_password_change_token, is_password_change_token_valid
 from misago.users.validators import validate_password
 
 

+ 2 - 2
misago/users/api/users.py

@@ -7,7 +7,7 @@ from django.utils.translation import ugettext as _
 
 from rest_framework import mixins, status, viewsets
 from rest_framework.decorators import detail_route, list_route
-from rest_framework.parsers import JSONParser, MultiPartParser
+from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
 from rest_framework.response import Response
 
 from misago.acl import add_acl
@@ -54,7 +54,7 @@ def allow_self_only(user, pk, message):
 
 class UserViewSet(viewsets.GenericViewSet):
     permission_classes = (UserViewSetPermission,)
-    parser_classes=(JSONParser, MultiPartParser)
+    parser_classes=(FormParser, JSONParser, MultiPartParser)
     queryset = get_user_model().objects
 
     def get_queryset(self):

+ 1 - 2
misago/users/rest_permissions.py

@@ -20,8 +20,7 @@ __all__ = [
 class IsAuthenticatedOrReadOnly(BasePermission):
     def has_permission(self, request, view):
         if request.user.is_anonymous() and request.method not in SAFE_METHODS:
-            raise PermissionDenied(
-                _("This action is not available to guests."))
+            raise PermissionDenied(_("This action is not available to guests."))
         else:
             return True