Browse Source

misc cleanups in threads and viewmodels, viewmodels for user content feeds

Rafał Pitoń 8 years ago
parent
commit
70b6bd7f5b

+ 3 - 0
misago/conf/defaults.py

@@ -346,6 +346,9 @@ REST_FRAMEWORK = {
     'DEFAULT_PERMISSION_CLASSES': (
         'misago.users.rest_permissions.IsAuthenticatedOrReadOnly',
     ),
+    'DEFAULT_RENDERER_CLASSES': (
+        'rest_framework.renderers.JSONRenderer',
+    ),
     'EXCEPTION_HANDLER': 'misago.core.exceptionhandler.handle_api_exception',
     'UNAUTHENTICATED_USER': 'misago.users.models.AnonymousUser',
     'URL_FORMAT_OVERRIDE': None,

+ 1 - 2
misago/core/apipaginator.py

@@ -1,5 +1,4 @@
-from django.core.paginator import Paginator as DjangoPaginator
-from django.core.paginator import InvalidPage
+from django.core.paginator import Paginator as DjangoPaginator, InvalidPage
 from django.utils import six
 
 from rest_framework.exceptions import NotFound

+ 2 - 2
misago/core/shortcuts.py

@@ -10,7 +10,7 @@ def paginate(object_list, page, per_page, orphans=0,
              allow_empty_first_page=True,
              allow_explicit_first_page=False):
     from django.http import Http404
-    from django.core.paginator import Paginator, EmptyPage
+    from django.core.paginator import Paginator, EmptyPage, InvalidPage
     from .exceptions import ExplicitFirstPage
 
     if page in (1, "1") and not allow_explicit_first_page:
@@ -22,7 +22,7 @@ def paginate(object_list, page, per_page, orphans=0,
         return Paginator(
             object_list, per_page, orphans=orphans,
             allow_empty_first_page=allow_empty_first_page).page(page)
-    except EmptyPage:
+    except (EmptyPage, InvalidPage):
         raise Http404()
 
 

+ 4 - 2
misago/readtracker/threadstracker.py

@@ -45,7 +45,8 @@ def make_categories_threads_read_aware(user, threads):
     for thread in threads:
         category_cutoff = categories_cutoffs.get(thread.category_id)
         thread.is_read = not is_date_tracked(thread.last_post_on, user, category_cutoff)
-        thread.is_new = True
+        thread.is_new = not thread.is_read
+        thread.last_read_on = user.joined_on
 
         if not thread.is_read:
             threads_dict[thread.pk] = thread
@@ -70,8 +71,9 @@ def make_threads_dict_read_aware(user, threads_dict):
     for record in user.threadread_set.filter(thread__in=threads_dict.keys()):
         if record.thread_id in threads_dict:
             thread = threads_dict[record.thread_id]
-            thread.is_new = False
             thread.is_read = record.last_read_on >= thread.last_post_on
+            thread.is_new = not thread.is_read
+            thread.last_read_on = record.last_read_on
 
 
 def make_thread_read_aware(user, thread):

+ 2 - 2
misago/threads/api/threadendpoints/merge.py

@@ -13,7 +13,7 @@ from ...moderation import threads as moderation
 from ...permissions import can_reply_thread, can_see_thread
 from ...serializers import NewThreadSerializer, ThreadsListSerializer
 from ...threadtypes import trees_map
-from ...utils import add_categories_to_threads, get_thread_id_from_url
+from ...utils import add_categories_to_items, get_thread_id_from_url
 from .pollmergehandler import PollMergeHandler
 
 
@@ -224,7 +224,7 @@ def merge_threads(request, validated_data, threads, poll):
         categories = list(Category.objects.all_categories().filter(
             id__in=request.user.acl['visible_categories']
         ))
-        add_categories_to_threads(validated_data['top_category'], categories, [new_thread])
+        add_categories_to_items(validated_data['top_category'], categories, [new_thread])
     else:
         new_thread.top_category = None
 

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

@@ -11,7 +11,7 @@ from misago.core.shortcuts import get_int_or_404, get_object_or_404
 
 from ...moderation import threads as moderation
 from ...permissions import allow_start_thread
-from ...utils import add_categories_to_threads
+from ...utils import add_categories_to_items
 from ...validators import validate_title
 
 
@@ -102,7 +102,7 @@ def patch_top_category(request, thread, value):
     categories = list(Category.objects.all_categories().filter(
         id__in=request.user.acl['visible_categories']
     ))
-    add_categories_to_threads(root_category, categories, [thread])
+    add_categories_to_items(root_category, categories, [thread])
     return {'top_category': CategorySerializer(thread.top_category).data}
 thread_patch_dispatcher.add('top-category', patch_top_category)
 

+ 44 - 2
misago/threads/serializers/post.py

@@ -2,6 +2,8 @@ from django.core.urlresolvers import reverse
 
 from rest_framework import serializers
 
+from misago.categories.models import Category
+from misago.categories.serializers import BasicCategorySerializer
 from misago.users.serializers import UserSerializer
 
 from ..models import Post
@@ -9,6 +11,7 @@ from ..models import Post
 
 __all__ = [
     'PostSerializer',
+    'PostFeedSerializer',
 ]
 
 
@@ -32,7 +35,7 @@ class PostSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Post
-        fields = (
+        fields = [
             'id',
             'poster',
             'poster_name',
@@ -65,7 +68,7 @@ class PostSerializer(serializers.ModelSerializer):
 
             'api',
             'url',
-        )
+        ]
 
     def get_poster_ip(self, obj):
         if self.context['user'].acl['can_see_users_ips']:
@@ -164,3 +167,42 @@ class PostSerializer(serializers.ModelSerializer):
             })
         else:
             return None
+
+
+class CategoryFeedSerializer(BasicCategorySerializer):
+    class Meta:
+        model = Category
+        fields = (
+            'name',
+            'css_class',
+            'absolute_url',
+        )
+
+
+class PostFeedSerializer(PostSerializer):
+    category = CategoryFeedSerializer(many=False, read_only=True)
+
+    thread = serializers.SerializerMethodField()
+    top_category = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Post
+        fields = PostSerializer.Meta.fields + [
+            'category',
+
+            'thread',
+            'top_category'
+        ]
+
+    def get_thread(self, obj):
+        return {
+            'title': obj.thread.title,
+            'url': obj.thread.get_absolute_url()
+        }
+
+    def get_top_category(self, obj):
+        try:
+            return CategoryFeedSerializer(obj.top_category).data
+        except AttributeError:
+            return None
+

+ 3 - 7
misago/threads/tests/test_threads_api.py

@@ -1,7 +1,3 @@
-import json
-
-from django.utils.encoding import smart_str
-
 from misago.acl.testutils import override_acl
 from misago.categories.models import THREADS_ROOT_NAME, Category
 from misago.users.testutils import AuthenticatedUserTestCase
@@ -51,7 +47,7 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         response = self.client.get(self.thread.get_api_url())
         self.assertEqual(response.status_code, 200)
 
-        return json.loads(smart_str(response.content))
+        return response.json()
 
 
 class ThreadRetrieveApiTests(ThreadsApiTestCase):
@@ -72,7 +68,7 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             response = self.client.get(link)
             self.assertEqual(response.status_code, 200)
 
-            response_json = json.loads(smart_str(response.content))
+            response_json = response.json()
             self.assertEqual(response_json['id'], self.thread.pk)
             self.assertEqual(response_json['title'], self.thread.title)
 
@@ -182,7 +178,7 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
             'can_hide_threads': 0
         })
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['detail'],
             "You don't have permission to delete this thread.")
 

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

@@ -2,12 +2,12 @@ from misago.categories.models import Category
 from misago.core.testutils import MisagoTestCase
 
 from .. import testutils
-from ..utils import add_categories_to_threads, get_thread_id_from_url
+from ..utils import add_categories_to_items, get_thread_id_from_url
 
 
-class AddCategoriesToThreadsTests(MisagoTestCase):
+class AddCategoriesToItemsTests(MisagoTestCase):
     def setUp(self):
-        super(AddCategoriesToThreadsTests, self).setUp()
+        super(AddCategoriesToItemsTests, self).setUp()
 
         self.root = Category.objects.root_category()
 
@@ -84,7 +84,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_root_thread_from_root(self):
         """thread in root category is handled"""
         thread = testutils.post_thread(category=self.root)
-        add_categories_to_threads(self.root, self.categories, [thread])
+        add_categories_to_items(self.root, self.categories, [thread])
 
         self.assertIsNone(thread.top_category)
         self.assertEqual(thread.category, self.root)
@@ -92,7 +92,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_root_thread_from_elsewhere(self):
         """thread in root category is handled"""
         thread = testutils.post_thread(category=self.root)
-        add_categories_to_threads(self.category_e, self.categories, [thread])
+        add_categories_to_items(self.category_e, self.categories, [thread])
 
         self.assertIsNone(thread.top_category)
         self.assertEqual(thread.category, self.root)
@@ -100,7 +100,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_direct_child_thread_from_parent(self):
         """thread in direct child category is handled"""
         thread = testutils.post_thread(category=self.category_e)
-        add_categories_to_threads(self.root, self.categories, [thread])
+        add_categories_to_items(self.root, self.categories, [thread])
 
         self.assertEqual(thread.top_category, self.category_e)
         self.assertEqual(thread.category, self.category_e)
@@ -108,7 +108,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_direct_child_thread_from_elsewhere(self):
         """thread in direct child category is handled"""
         thread = testutils.post_thread(category=self.category_e)
-        add_categories_to_threads(self.category_b, self.categories, [thread])
+        add_categories_to_items(self.category_b, self.categories, [thread])
 
         self.assertEqual(thread.top_category, self.category_e)
         self.assertEqual(thread.category, self.category_e)
@@ -116,7 +116,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_child_thread_from_root(self):
         """thread in child category is handled"""
         thread = testutils.post_thread(category=self.category_d)
-        add_categories_to_threads(self.root, self.categories, [thread])
+        add_categories_to_items(self.root, self.categories, [thread])
 
         self.assertEqual(thread.top_category, self.category_a)
         self.assertEqual(thread.category, self.category_d)
@@ -124,7 +124,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_child_thread_from_parent(self):
         """thread in child category is handled"""
         thread = testutils.post_thread(category=self.category_d)
-        add_categories_to_threads(self.category_a, self.categories, [thread])
+        add_categories_to_items(self.category_a, self.categories, [thread])
 
         self.assertEqual(thread.top_category, self.category_b)
         self.assertEqual(thread.category, self.category_d)
@@ -132,7 +132,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_child_thread_from_category(self):
         """thread in child category is handled"""
         thread = testutils.post_thread(category=self.category_d)
-        add_categories_to_threads(self.category_d, self.categories, [thread])
+        add_categories_to_items(self.category_d, self.categories, [thread])
 
         self.assertIsNone(thread.top_category)
         self.assertEqual(thread.category, self.category_d)
@@ -140,7 +140,7 @@ class AddCategoriesToThreadsTests(MisagoTestCase):
     def test_child_thread_from_elsewhere(self):
         """thread in child category is handled"""
         thread = testutils.post_thread(category=self.category_d)
-        add_categories_to_threads(self.category_f, self.categories, [thread])
+        add_categories_to_items(self.category_f, self.categories, [thread])
 
         self.assertEqual(thread.top_category, self.category_a)
         self.assertEqual(thread.category, self.category_d)

+ 19 - 19
misago/threads/utils.py

@@ -5,38 +5,38 @@ from django.utils.six.moves.urllib.parse import urlparse
 from .models import PostLike
 
 
-def add_categories_to_threads(root_category, categories, threads):
+def add_categories_to_items(root_category, categories, items):
     categories_dict = {}
     for category in categories:
         categories_dict[category.pk] = category
 
     top_categories_map = {}
 
-    for thread in threads:
-        thread.top_category = None
-        thread.category = categories_dict[thread.category_id]
+    for item in items:
+        item.top_category = None
+        item.category = categories_dict[item.category_id]
 
-        if thread.category == root_category:
+        if item.category == root_category:
             continue
-        elif thread.category.parent_id == root_category.pk:
-            thread.top_category = thread.category
-        elif thread.category_id in top_categories_map:
-            thread.top_category = top_categories_map[thread.category_id]
-        elif root_category.has_child(thread.category):
-            # thread in subcategory resolution
+        elif item.category.parent_id == root_category.pk:
+            item.top_category = item.category
+        elif item.category_id in top_categories_map:
+            item.top_category = top_categories_map[item.category_id]
+        elif root_category.has_child(item.category):
+            # item in subcategory resolution
             for category in categories:
                 if (category.parent_id == root_category.pk and
-                        category.has_child(thread.category)):
-                    top_categories_map[thread.category_id] = category
-                    thread.top_category = category
+                        category.has_child(item.category)):
+                    top_categories_map[item.category_id] = category
+                    item.top_category = category
         else:
-            # global thread in other category resolution
+            # item from other category's scope
             for category in categories:
                 if category.level == 1 and (
-                        category == thread.category or
-                        category.has_child(thread.category)):
-                    top_categories_map[thread.category_id] = category
-                    thread.top_category = category
+                        category == item.category or
+                        category.has_child(item.category)):
+                    top_categories_map[item.category_id] = category
+                    item.top_category = category
 
 
 def add_likes_to_posts(user, posts):

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

@@ -16,7 +16,7 @@ from ..models import Thread
 from ..permissions import exclude_invisible_threads
 from ..serializers import ThreadsListSerializer
 from ..subscriptions import make_subscription_aware
-from ..utils import add_categories_to_threads
+from ..utils import add_categories_to_items
 
 
 __all__ = ['ForumThreads', 'PrivateThreads']
@@ -66,7 +66,7 @@ class ViewModel(object):
         else:
             threadstracker.make_threads_read_aware(request.user, threads)
 
-        add_categories_to_threads(category_model, category.categories, threads)
+        add_categories_to_items(category_model, category.categories, threads)
         add_acl(request.user, threads)
         make_subscription_aware(request.user, threads)
 

+ 25 - 0
misago/users/api/users.py

@@ -25,6 +25,7 @@ from ..permissions.moderation import allow_moderate_avatar, allow_rename_user
 from ..permissions.profiles import allow_browse_users_list, allow_follow_user, allow_see_ban_details
 from ..rest_permissions import BasePermission, IsAuthenticatedOrReadOnly, UnbannedAnonOnly
 from ..serializers import BanDetailsSerializer, UserProfileSerializer, UserSerializer
+from ..viewmodels import UserPosts, UserThreads
 from .userendpoints.avatar import avatar_endpoint, moderate_avatar_endpoint
 from .userendpoints.changeemail import change_email_endpoint
 from .userendpoints.changepassword import change_password_endpoint
@@ -217,3 +218,27 @@ class UserViewSet(viewsets.GenericViewSet):
                 profile.delete()
 
         return Response({'detail': 'ok'})
+
+    @detail_route(methods=['get'])
+    def threads(self, request, pk=None):
+        profile = self.get_user(pk)
+
+        page = get_int_or_404(request.query_params.get('page', 0))
+        if page == 1:
+            page = 0 # api allows explicit first page
+
+        feed = UserThreads(request, profile, page)
+
+        return Response(feed.get_frontend_context())
+
+    @detail_route(methods=['get'])
+    def posts(self, request, pk=None):
+        profile = self.get_user(pk)
+
+        page = get_int_or_404(request.query_params.get('page', 0))
+        if page == 1:
+            page = 0 # api allows explicit first page
+
+        feed = UserPosts(request, profile, page)
+
+        return Response(feed.get_frontend_context())

+ 1 - 2
misago/users/rest_permissions.py

@@ -43,8 +43,7 @@ class UnbannedOnly(BasePermission):
 class UnbannedAnonOnly(UnbannedOnly):
     def has_permission(self, request, view):
         if request.user.is_authenticated():
-            raise PermissionDenied(
-                _("This action is not available to signed in users."))
+            raise PermissionDenied(_("This action is not available to signed in users."))
 
         self.is_request_banned(request)
         return True

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

@@ -1,3 +1,4 @@
 from .ban import *
 from .rank import *
 from .user import *
+from .userfeed import UserFeedSerializer

+ 2 - 0
misago/users/serializers/user.py

@@ -250,4 +250,6 @@ class UserProfileSerializer(UserSerializer):
             'moderate_username': reverse(
                 'misago:api:user-moderate-username',kwargs={'pk': obj.pk}),
             'delete': reverse('misago:api:user-delete', kwargs={'pk': obj.pk}),
+            'threads': reverse('misago:api:user-threads', kwargs={'pk': obj.pk}),
+            'posts': reverse('misago:api:user-posts', kwargs={'pk': obj.pk}),
         }

+ 11 - 0
misago/users/serializers/userfeed.py

@@ -0,0 +1,11 @@
+from misago.threads.models import Post
+from misago.threads.serializers import PostFeedSerializer
+
+
+class UserFeedSerializer(PostFeedSerializer):
+    class Meta:
+        model = Post
+        fields = PostFeedSerializer.Meta.fields
+
+        fields.remove('poster')
+        fields.remove('poster_name')

+ 122 - 0
misago/users/tests/test_user_feeds_api.py

@@ -0,0 +1,122 @@
+from django.core.urlresolvers import reverse
+
+from misago.threads import testutils
+from misago.threads.tests.test_threads_api import ThreadsApiTestCase
+
+
+class UserThreadsApiTests(ThreadsApiTestCase):
+    def setUp(self):
+        super(UserThreadsApiTests, self).setUp()
+
+        self.api_link = reverse('misago:api:user-threads', kwargs={'pk': self.user.pk})
+
+    def test_invalid_user_id(self):
+        """api validates user id"""
+        link = reverse('misago:api:user-threads', kwargs={'pk': 'abcd'})
+        response = self.client.get(link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistant_user_id(self):
+        """api validates that user for id exists"""
+        link = reverse('misago:api:user-threads', kwargs={'pk': self.user.pk + 1})
+        response = self.client.get(link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_empty_response(self):
+        """api has no showstopers on empty response"""
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 0)
+
+    def test_user_post(self):
+        """user post doesn't show in feed because its not first post in thread"""
+        testutils.reply_thread(self.thread, poster=self.user)
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 0)
+
+    def test_user_thread(self):
+        """user thread shows in feed"""
+        thread = testutils.post_thread(category=self.category, poster=self.user)
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 1)
+        self.assertEqual(response.json()['results'][0]['id'], thread.first_post_id)
+
+    def test_user_thread_anonymous(self):
+        """user thread shows in feed requested by unauthenticated user"""
+        thread = testutils.post_thread(category=self.category, poster=self.user)
+
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 1)
+        self.assertEqual(response.json()['results'][0]['id'], thread.first_post_id)
+
+
+class UserPostsApiTests(ThreadsApiTestCase):
+    def setUp(self):
+        super(UserPostsApiTests, self).setUp()
+
+        self.api_link = reverse('misago:api:user-posts', kwargs={'pk': self.user.pk})
+
+    def test_invalid_user_id(self):
+        """api validates user id"""
+        link = reverse('misago:api:user-posts', kwargs={'pk': 'abcd'})
+        response = self.client.get(link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistant_user_id(self):
+        """api validates that user for id exists"""
+        link = reverse('misago:api:user-posts', kwargs={'pk': self.user.pk + 1})
+        response = self.client.get(link)
+        self.assertEqual(response.status_code, 404)
+
+    def test_empty_response(self):
+        """api has no showstopers on empty response"""
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 0)
+
+    def test_user_post(self):
+        """user post shows in feed"""
+        post = testutils.reply_thread(self.thread, poster=self.user)
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 1)
+        self.assertEqual(response.json()['results'][0]['id'], post.pk)
+
+    def test_user_thread(self):
+        """user thread shows in feed"""
+        thread = testutils.post_thread(category=self.category, poster=self.user)
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 1)
+        self.assertEqual(response.json()['results'][0]['id'], thread.first_post_id)
+
+    def test_user_post_anonymous(self):
+        """user post shows in feed requested by unauthenticated user"""
+        post = testutils.reply_thread(self.thread, poster=self.user)
+
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 1)
+        self.assertEqual(response.json()['results'][0]['id'], post.pk)
+
+    def test_user_thread_anonymous(self):
+        """user thread shows in feed requested by unauthenticated user"""
+        thread = testutils.post_thread(category=self.category, poster=self.user)
+
+        self.logout_user()
+
+        response = self.client.get(self.api_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()['count'], 1)
+        self.assertEqual(response.json()['results'][0]['id'], thread.first_post_id)

+ 2 - 0
misago/users/viewmodels/__init__.py

@@ -0,0 +1,2 @@
+from .threads import UserThreads
+from .posts import UserPosts

+ 15 - 0
misago/users/viewmodels/posts.py

@@ -0,0 +1,15 @@
+from misago.threads.models import Thread
+from misago.threads.permissions import exclude_invisible_threads
+
+from .threads import UserThreads
+
+
+class UserPosts(UserThreads):
+    def get_threads_queryset(self, request, threads_categories, profile):
+        return exclude_invisible_threads(
+            request.user, threads_categories, Thread.objects)
+
+    def get_posts_queryset(self, user, profile, threads_queryset):
+        return profile.post_set.select_related('thread').filter(
+            thread_id__in=threads_queryset.values('id')
+        )

+ 86 - 0
misago/users/viewmodels/threads.py

@@ -0,0 +1,86 @@
+from django.conf import settings
+
+from misago.acl import add_acl
+from misago.core.shortcuts import paginate, pagination_dict
+from misago.readtracker import threadstracker
+from misago.threads.permissions import exclude_invisible_threads
+from misago.threads.threadtypes import trees_map
+from misago.threads.subscriptions import make_subscription_aware
+from misago.threads.utils import add_categories_to_items, add_likes_to_posts
+from misago.threads.viewmodels import ThreadsRootCategory
+from misago.users.online.utils import make_users_status_aware
+
+from ..serializers import UserFeedSerializer
+
+
+class UserThreads(object):
+    def __init__(self, request, profile, page=0):
+        root_category = ThreadsRootCategory(request)
+        threads_categories = [root_category.unwrap()] + root_category.subcategories
+
+        threads_queryset = self.get_threads_queryset(
+            request, threads_categories, profile)
+        posts_queryset = self.get_posts_queryset(
+            request.user, profile, threads_queryset
+            ).filter(
+                is_hidden=False,
+                is_unapproved=False
+            ).order_by('-pk')
+
+        list_page = paginate(
+            posts_queryset, page, settings.MISAGO_POSTS_PER_PAGE, settings.MISAGO_POSTS_TAIL)
+        paginator = pagination_dict(list_page, include_page_range=False)
+
+        posts = list(list_page.object_list)
+
+        posters = []
+        threads = []
+
+        for post in posts:
+            threads.append(post.thread)
+
+            if post.poster:
+                posters.append(post.poster)
+
+        add_categories_to_items(
+            root_category.unwrap(), threads_categories, posts + threads)
+
+        add_acl(request.user, threads)
+        add_acl(request.user, posts)
+
+        threadstracker.make_threads_read_aware(request.user, threads)
+        for post in posts:
+            threadstracker.make_posts_read_aware(request.user, post.thread, [post])
+
+        add_likes_to_posts(request.user, posts)
+
+        make_users_status_aware(request.user, posters)
+
+        self._user = request.user
+
+        self.posts = posts
+        self.paginator = paginator
+
+    def get_threads_queryset(self, request, threads_categories, profile):
+        return exclude_invisible_threads(
+            request.user, threads_categories, profile.thread_set)
+
+    def get_posts_queryset(self, user, profile, threads_queryset):
+        return profile.post_set.select_related('thread').filter(
+            id__in=threads_queryset.values('first_post_id')
+        )
+
+    def get_frontend_context(self):
+        context = {
+            'results': UserFeedSerializer(self.posts, many=True, context={'user': self._user}).data
+        }
+
+        context.update(self.paginator)
+
+        return context
+
+    def get_template_context(self):
+        return {
+            'feed': self.posts,
+            'paginator': self.paginator
+        }

+ 19 - 4
misago/users/views/profile.py

@@ -24,6 +24,7 @@ from ..permissions.profiles import allow_block_user, allow_follow_user
 from ..serializers import BanDetailsSerializer, UserProfileSerializer, UserSerializer
 from ..serializers.usernamechange import UsernameChangeSerializer
 from ..warnings import get_user_warning_level, get_user_warning_obj, get_warning_levels
+from ..viewmodels import UserPosts, UserThreads
 
 
 def profile_view(f):
@@ -109,16 +110,30 @@ def landing(request, profile):
 
 @profile_view
 def posts(request, profile):
-    return render(request, 'misago/profile/posts.html', {
+    context = {
         'profile': profile
-    })
+    }
+
+    feed = UserPosts(request, profile)
+    context.update(feed.get_template_context())
+
+    request.frontend_context['FEED'] = feed.get_frontend_context()
+
+    return render(request, 'misago/profile/posts.html', context)
 
 
 @profile_view
 def threads(request, profile):
-    return render(request, 'misago/profile/threads.html', {
+    context = {
         'profile': profile
-    })
+    }
+
+    feed = UserThreads(request, profile)
+    context.update(feed.get_template_context())
+
+    request.frontend_context['FEED'] = feed.get_frontend_context()
+
+    return render(request, 'misago/profile/threads.html', context)
 
 
 @profile_view