Browse Source

#675: moved views to per-post readtracking

Rafał Pitoń 7 years ago
parent
commit
7e3ed7799b

+ 4 - 8
misago/readtracker/poststracker.py

@@ -5,17 +5,13 @@ from .dates import get_cutoff_date
 from .models import PostRead
 
 
-def make_read_aware(user, target):
-    if not target:
+def make_read_aware(user, posts):
+    if not posts:
         return
 
-    if hasattr(target, '__iter__'):
-        make_posts_read_aware(user, target)
-    else:
-        make_posts_read_aware(user, [target])
+    if not hasattr(posts, '__iter__'):
+        posts = [posts]
 
-
-def make_posts_read_aware(user, posts):
     make_read(posts)
 
     if user.is_anonymous:

+ 0 - 3
misago/readtracker/signals.py

@@ -5,9 +5,6 @@ from misago.categories.signals import delete_category_content, move_category_con
 from misago.threads.signals import merge_thread, move_thread, move_post
 
 
-all_read = Signal()
-category_read = Signal(providing_args=["category"])
-thread_tracked = Signal(providing_args=["thread"])
 thread_read = Signal(providing_args=["thread"])
 
 

+ 0 - 307
misago/readtracker/tests/test_readtracker.py

@@ -1,307 +0,0 @@
-from datetime import timedelta
-
-from django.contrib.auth import get_user_model
-from django.test import TestCase
-from django.utils import timezone
-
-from misago.acl import add_acl
-from misago.categories.models import Category
-from misago.readtracker import categoriestracker, threadstracker
-from misago.threads import testutils
-from misago.users.models import AnonymousUser
-
-
-UserModel = get_user_model()
-
-
-class ReadTrackerTests(TestCase):
-    def setUp(self):
-        self.categories = list(Category.objects.all_categories()[:1])
-        self.category = self.categories[0]
-
-        self.user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
-        self.anon = AnonymousUser()
-
-    def post_thread(self, datetime):
-        return testutils.post_thread(
-            category=self.category,
-            started_on=datetime,
-        )
-
-
-class CategoriesTrackerTests(ReadTrackerTests):
-    def test_anon_empty_category_read(self):
-        """anon users content is always read"""
-        categoriestracker.make_read_aware(self.anon, self.categories)
-        self.assertIsNone(self.category.last_post_on)
-        self.assertTrue(self.category.is_read)
-
-    def test_anon_category_recent_reply_read(self):
-        """anon users content is always read"""
-        categoriestracker.make_read_aware(self.anon, self.categories)
-        self.category.last_post_on = timezone.now()
-        self.assertTrue(self.category.is_read)
-
-    def test_empty_category_is_read(self):
-        """empty category is read for signed in user"""
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-    def test_make_read_aware_sets_read_flag_for_empty_category(self):
-        """make_read_aware sets read flag on empty category"""
-        categoriestracker.make_read_aware(self.anon, self.categories)
-        self.assertTrue(self.category.is_read)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-    def test_make_read_aware_sets_read_flag_for_category_old_thread(self):
-        """make_read_aware sets read flag on category with old thread"""
-        self.category.last_post_on = self.user.joined_on - timedelta(days=1)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-    def test_make_read_aware_sets_unread_flag_for_category_new_thread(self):
-        """make_read_aware sets unread flag on category with new thread"""
-        self.category.last_post_on = self.user.joined_on + timedelta(days=1)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-    def test_sync_record_for_empty_category(self):
-        """sync_record sets read flag on empty category"""
-        add_acl(self.user, self.categories)
-        categoriestracker.sync_record(self.user, self.category)
-        self.user.categoryread_set.get(category=self.category)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-    def test_sync_record_for_category_old_thread_and_reply(self):
-        """
-        sync_record sets read flag on category with old thread,
-        then changes flag to unread when new reply is posted
-        """
-        self.post_thread(self.user.joined_on - timedelta(days=1))
-
-        add_acl(self.user, self.categories)
-        categoriestracker.sync_record(self.user, self.category)
-        self.user.categoryread_set.get(category=self.category)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-
-        categoriestracker.sync_record(self.user, self.category)
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-    def test_sync_record_for_category_new_thread(self):
-        """
-        sync_record sets read flag on category with old thread,
-        then keeps flag to unread when new reply is posted
-        """
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-
-        add_acl(self.user, self.categories)
-        categoriestracker.sync_record(self.user, self.category)
-        self.user.categoryread_set.get(category=self.category)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-        categoriestracker.sync_record(self.user, self.category)
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-    def test_sync_record_for_category_deleted_threads(self):
-        """unread category reverts to read after its emptied"""
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-
-        add_acl(self.user, self.categories)
-        categoriestracker.sync_record(self.user, self.category)
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-        self.category.thread_set.all().delete()
-        self.category.synchronize()
-        self.category.save()
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-    def test_sync_record_for_category_many_threads(self):
-        """sync_record sets unread flag on category with many threads"""
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-        self.post_thread(self.user.joined_on - timedelta(days=1))
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-        self.post_thread(self.user.joined_on - timedelta(days=1))
-
-        add_acl(self.user, self.categories)
-        categoriestracker.sync_record(self.user, self.category)
-        self.user.categoryread_set.get(category=self.category)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-        self.post_thread(self.user.joined_on + timedelta(days=1))
-        categoriestracker.sync_record(self.user, self.category)
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-    def test_sync_record_for_category_threads_behind_cutoff(self):
-        """
-        sync_record sets read flag on category with only thread being behind cutoff
-        """
-        self.post_thread(timezone.now() - timedelta(days=180))
-
-        read_thread = self.post_thread(timezone.now())
-
-        threadstracker.make_read_aware(self.user, read_thread)
-        threadstracker.read_thread(self.user, read_thread, read_thread.last_post)
-
-        category = Category.objects.get(pk=self.category.pk)
-        categoriestracker.make_read_aware(self.user, [category])
-        self.assertTrue(category.is_read)
-
-    def test_read_leaf_category(self):
-        """read_category reads leaf category for user"""
-        categoriestracker.read_category(self.user, self.category)
-
-        self.assertTrue(self.user.categoryread_set.get(category=self.category))
-
-    def test_read_root_category(self):
-        """read_category reads its subcategories for user"""
-        root_category = Category.objects.root_category()
-        categoriestracker.read_category(self.user, root_category)
-
-        child_read = self.user.categoryread_set.get(category=self.category)
-        self.assertTrue(child_read.last_read_on > timezone.now() - timedelta(seconds=3))
-
-    def test_read_category_prunes_threadreads(self):
-        """read_category prunes threadreads in this category"""
-        thread = self.post_thread(timezone.now())
-
-        threadstracker.make_read_aware(self.user, thread)
-        threadstracker.read_thread(self.user, thread, thread.last_post)
-
-        self.assertTrue(self.user.threadread_set.exists())
-
-        categoriestracker.read_category(self.user, self.category)
-
-        self.assertTrue(self.user.categoryread_set.get(category=self.category))
-        self.assertFalse(self.user.threadread_set.exists())
-
-
-class ThreadsTrackerTests(ReadTrackerTests):
-    def setUp(self):
-        super(ThreadsTrackerTests, self).setUp()
-
-        self.thread = self.post_thread(timezone.now() - timedelta(days=10))
-
-    def reply_thread(self, is_hidden=False, is_unapproved=False):
-        self.post = testutils.reply_thread(
-            thread=self.thread,
-            is_hidden=is_hidden,
-            is_unapproved=is_unapproved,
-            posted_on=timezone.now(),
-        )
-        return self.post
-
-    def test_thread_read_for_guest(self):
-        """threads are always read for guests"""
-        threadstracker.make_read_aware(self.anon, self.thread)
-        self.assertTrue(self.thread.is_read)
-
-        self.reply_thread()
-        threadstracker.make_read_aware(self.anon, [self.thread])
-        self.assertTrue(self.thread.is_read)
-
-    def test_thread_read_for_user(self):
-        """thread is read for user"""
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertTrue(self.thread.is_read)
-
-    def test_thread_replied_unread_for_user(self):
-        """replied thread is unread for user"""
-        self.reply_thread()
-
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertFalse(self.thread.is_read)
-
-    def test_thread_read(self):
-        """thread read flag is set for user, then its set as unread by reply"""
-        self.reply_thread()
-
-        add_acl(self.user, self.categories)
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertFalse(self.thread.is_read)
-
-        threadstracker.read_thread(self.user, self.thread, self.post)
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertTrue(self.thread.is_read)
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-        self.thread.last_post_on = timezone.now()
-        self.thread.save()
-        self.category.synchronize()
-        self.category.save()
-
-        self.reply_thread()
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertFalse(self.thread.is_read)
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertFalse(self.category.is_read)
-
-        posts = [post for post in self.thread.post_set.order_by('id')]
-        threadstracker.make_posts_read_aware(self.user, self.thread, posts)
-
-        for post in posts[:-1]:
-            self.assertTrue(post.is_read)
-        self.assertFalse(posts[-1].is_read)
-
-    def test_thread_read_category_cutoff(self):
-        """thread read is handled when category cutoff is present"""
-        self.reply_thread()
-
-        add_acl(self.user, self.categories)
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertFalse(self.thread.is_read)
-
-        categoriestracker.read_category(self.user, self.category)
-        threadstracker.make_read_aware(self.user, self.thread)
-        self.assertTrue(self.thread.is_read)
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)
-
-        posts = list(self.thread.post_set.order_by('id'))
-        threadstracker.make_posts_read_aware(self.user, self.thread, posts)
-
-        for post in posts:
-            self.assertTrue(post.is_read)
-
-        # post reply
-        self.reply_thread()
-
-        # test if only last post is unread
-        posts = list(self.thread.post_set.order_by('id'))
-        threadstracker.make_read_aware(self.user, self.thread)
-        threadstracker.make_posts_read_aware(self.user, self.thread, posts)
-
-        for post in posts[:-1]:
-            self.assertTrue(post.is_read)
-        self.assertTrue(posts[-1].is_new)
-
-        # last post read will change readstate of categories
-        threadstracker.make_read_aware(self.user, self.thread)
-        threadstracker.read_thread(self.user, self.thread, posts[-1])
-
-        categoriestracker.make_read_aware(self.user, self.categories)
-        self.assertTrue(self.category.is_read)

+ 14 - 8
misago/threads/api/postendpoints/read.py

@@ -1,16 +1,22 @@
 from rest_framework.response import Response
 
-from misago.readtracker.threadstracker import make_posts_read_aware, read_thread
+from misago.readtracker import poststracker, threadstracker
+from misago.readtracker.signals import thread_read
 
 
 def post_read_endpoint(request, thread, post):
-    make_posts_read_aware(request.user, thread, [post])
-    if not post.is_read:
-        read_thread(request.user, thread, post)
+    poststracker.make_read_aware(request.user, post)
+    if post.is_new:
+        poststracker.save_read(request.user, post)
         if thread.subscription and thread.subscription.last_read_on < post.posted_on:
             thread.subscription.last_read_on = post.posted_on
             thread.subscription.save()
-        return Response({
-            'thread_is_read': thread.last_post_on <= post.posted_on,
-        })
-    return Response({'thread_is_read': True})
+
+    threadstracker.make_read_aware(request.user, thread)
+
+    # send signal if post read marked thread as read
+    # used in some places, eg. syncing unread thread count
+    if post.is_new and thread.is_read:
+        thread_read.send(request.user, thread=thread)
+
+    return Response({ 'thread_is_read': thread.is_read })

+ 4 - 0
misago/threads/permissions/threads.py

@@ -1363,6 +1363,8 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
     hide_invisible_events = []
 
     for category in categories:
+        add_acl(user, category)
+
         if category.acl['can_approve_content']:
             show_all.append(category.pk)
         else:
@@ -1414,6 +1416,8 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
 
 
 def exclude_invisible_posts_in_category(user, category, queryset):
+    add_acl(user, category)
+
     if not category.acl['can_approve_content']:
         if user.is_authenticated:
             queryset = queryset.filter(Q(is_unapproved=False) | Q(poster=user))

+ 11 - 15
misago/threads/tests/test_gotoviews.py

@@ -3,7 +3,7 @@ from django.utils import timezone
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.conf import settings
-from misago.readtracker.threadstracker import make_thread_read_aware, read_thread
+from misago.readtracker.poststracker import save_read
 from misago.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -144,8 +144,7 @@ class GotoNewTests(GotoViewTestCase):
 
     def test_goto_first_new_post(self):
         """first unread post redirect url in already read thread is valid"""
-        make_thread_read_aware(self.user, self.thread)
-        read_thread(self.user, self.thread, self.thread.last_post)
+        save_read(self.user, self.thread.first_post)
 
         post = testutils.reply_thread(self.thread, posted_on=timezone.now())
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
@@ -159,11 +158,11 @@ class GotoNewTests(GotoViewTestCase):
 
     def test_goto_first_new_post_on_next_page(self):
         """first unread post redirect url in already read multipage thread is valid"""
-        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
-            testutils.reply_thread(self.thread, posted_on=timezone.now())
+        save_read(self.user, self.thread.first_post)
 
-        make_thread_read_aware(self.user, self.thread)
-        read_thread(self.user, self.thread, self.thread.last_post)
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+            last_post = testutils.reply_thread(self.thread, posted_on=timezone.now())
+            save_read(self.user, last_post)
 
         post = testutils.reply_thread(self.thread, posted_on=timezone.now())
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
@@ -177,11 +176,11 @@ class GotoNewTests(GotoViewTestCase):
 
     def test_goto_first_new_post_in_read_thread(self):
         """goto new in read thread points to last post"""
+        save_read(self.user, self.thread.first_post)
+
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             post = testutils.reply_thread(self.thread, posted_on=timezone.now())
-
-        make_thread_read_aware(self.user, self.thread)
-        read_thread(self.user, self.thread, self.thread.last_post)
+            save_read(self.user, post)
 
         response = self.client.get(self.thread.get_new_post_url())
         self.assertEqual(response.status_code, 302)
@@ -229,21 +228,18 @@ class GotoUnapprovedTests(GotoViewTestCase):
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
         )
 
-    def test_vie_handles_unapproved_posts(self):
+    def test_view_handles_unapproved_posts(self):
         """if thread has unapproved posts, redirect to first of them"""
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
-        make_thread_read_aware(self.user, self.thread)
-        read_thread(self.user, self.thread, self.thread.last_post)
-
         post = testutils.reply_thread(self.thread, is_unapproved=True, posted_on=timezone.now())
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         self.grant_permission()
 
-        response = self.client.get(self.thread.get_new_post_url())
+        response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
             response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)

+ 20 - 12
misago/threads/tests/test_thread_postread_api.py

@@ -36,12 +36,27 @@ class PostReadApiTests(ThreadsApiTestCase):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        thread_read = self.user.threadread_set.order_by('id').last()
-        self.assertEqual(thread_read.thread_id, self.thread.id)
-        self.assertEqual(thread_read.last_read_on, self.post.posted_on)
+        self.assertEqual(self.user.postread_set.count(), 1)
+        self.user.postread_set.get(post=self.post)
 
-        category_read = self.user.categoryread_set.order_by('id').last()
-        self.assertTrue(category_read.last_read_on >= self.post.posted_on)
+        # one post read, first post is still unread
+        self.assertFalse(response.json()['thread_is_read'])
+
+        # read second post
+        response = self.client.post(reverse(
+            'misago:api:thread-post-read',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.thread.first_post.pk,
+            }
+        ))
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(self.user.postread_set.count(), 2)
+        self.user.postread_set.get(post=self.thread.first_post)
+
+        # both posts are read
+        self.assertTrue(response.json()['thread_is_read'])
 
     def test_read_subscribed_thread_post(self):
         """api marks post as read and updates subscription"""
@@ -55,12 +70,5 @@ class PostReadApiTests(ThreadsApiTestCase):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        thread_read = self.user.threadread_set.order_by('id').last()
-        self.assertEqual(thread_read.thread_id, self.thread.id)
-        self.assertEqual(thread_read.last_read_on, self.post.posted_on)
-
-        category_read = self.user.categoryread_set.order_by('id').last()
-        self.assertTrue(category_read.last_read_on >= self.post.posted_on)
-
         subscription = self.thread.subscription_set.order_by('id').last()
         self.assertEqual(subscription.last_read_on, self.post.posted_on)

+ 6 - 99
misago/threads/tests/test_threadslists.py

@@ -7,7 +7,7 @@ from django.utils.encoding import smart_str
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.conf import settings
-from misago.readtracker import threadstracker
+from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.users.models import AnonymousUser
 from misago.users.testutils import AuthenticatedUserTestCase
@@ -1069,47 +1069,7 @@ class NewThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(category=self.category_a)
 
-        threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
-
-        self.access_all_categories()
-
-        response = self.client.get('/new/')
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContainsThread(response, test_thread)
-
-        self.access_all_categories()
-
-        response = self.client.get(self.category_a.get_absolute_url() + 'new/')
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContainsThread(response, test_thread)
-
-        # test api
-        self.access_all_categories()
-        response = self.client.get('%s?list=new' % self.api_link)
-        self.assertEqual(response.status_code, 200)
-
-        response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
-
-        self.access_all_categories()
-        response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
-        self.assertEqual(response.status_code, 200)
-
-        response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
-
-    def test_list_hides_category_read_thread(self):
-        """list hides thread already read by user"""
-        self.user.joined_on = timezone.now() - timedelta(days=5)
-        self.user.save()
-
-        test_thread = testutils.post_thread(category=self.category_a)
-
-        self.user.categoryread_set.create(
-            category=self.category_a,
-            last_read_on=timezone.now(),
-        )
+        poststracker.save_read(self.user, test_thread.first_post)
 
         self.access_all_categories()
 
@@ -1178,8 +1138,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(category=self.category_a)
 
-        threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
+        poststracker.save_read(self.user, test_thread.first_post)
 
         testutils.reply_thread(test_thread)
 
@@ -1257,8 +1216,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(category=self.category_a)
 
-        threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
+        poststracker.save_read(self.user, test_thread.first_post)
 
         self.access_all_categories()
 
@@ -1299,8 +1257,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
             started_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 5),
         )
 
-        threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
+        poststracker.save_read(self.user, test_thread.first_post)
 
         testutils.reply_thread(test_thread, posted_on=test_thread.started_on + timedelta(days=1))
 
@@ -1343,8 +1300,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
             started_on=self.user.joined_on - timedelta(days=2),
         )
 
-        threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
+        poststracker.save_read(self.user, test_thread.first_post)
 
         testutils.reply_thread(
             test_thread,
@@ -1380,55 +1336,6 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-    def test_list_hides_category_cutoff_thread(self):
-        """list hides thread replied before category cutoff"""
-        self.user.joined_on = timezone.now() - timedelta(days=10)
-        self.user.save()
-
-        test_thread = testutils.post_thread(
-            category=self.category_a,
-            started_on=self.user.joined_on - timedelta(days=2),
-        )
-
-        threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
-
-        testutils.reply_thread(test_thread)
-
-        self.user.categoryread_set.create(
-            category=self.category_a,
-            last_read_on=timezone.now(),
-        )
-
-        self.access_all_categories()
-
-        response = self.client.get('/unread/')
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContainsThread(response, test_thread)
-
-        self.access_all_categories()
-
-        response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContainsThread(response, test_thread)
-
-        # test api
-        self.access_all_categories()
-        response = self.client.get('%s?list=unread' % self.api_link)
-        self.assertEqual(response.status_code, 200)
-
-        response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
-
-        self.access_all_categories()
-        response = self.client.get(
-            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
-        )
-        self.assertEqual(response.status_code, 200)
-
-        response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
-
 
 class SubscribedThreadsListTests(ThreadsListTestCase):
     def test_list_shows_subscribed_thread(self):

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

@@ -1,7 +1,7 @@
 from misago.acl import add_acl
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
-from misago.readtracker.poststracker import make_posts_read_aware
+from misago.readtracker.poststracker import make_read_aware
 from misago.threads.paginator import PostsPaginator
 from misago.threads.permissions import exclude_invisible_posts
 from misago.threads.serializers import PostSerializer
@@ -62,7 +62,7 @@ class ViewModel(object):
 
         # make posts and events ACL and reads aware
         add_acl(request.user, posts)
-        make_posts_read_aware(request.user, thread_model, posts)
+        make_read_aware(request.user, posts)
 
         self._user = request.user
 

+ 19 - 51
misago/threads/viewmodels/threads.py

@@ -11,9 +11,10 @@ from misago.acl import add_acl
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker import threadstracker
-from misago.threads.models import Thread
+from misago.readtracker.dates import get_cutoff_date
+from misago.threads.models import Post, Thread
 from misago.threads.participants import make_participants_aware
-from misago.threads.permissions import exclude_invisible_threads
+from misago.threads.permissions import exclude_invisible_posts, exclude_invisible_threads
 from misago.threads.serializers import ThreadsListSerializer
 from misago.threads.subscriptions import make_subscription_aware
 from misago.threads.utils import add_categories_to_items
@@ -69,9 +70,11 @@ class ViewModel(object):
 
         if list_type in ('new', 'unread'):
             # we already know all threads on list are unread
-            threadstracker.make_unread(threads)
+            for thread in threads:
+                thread.is_read = False
+                thread.is_new = True
         else:
-            threadstracker.make_threads_read_aware(request.user, threads)
+            threadstracker.make_read_aware(request.user, threads)
 
         add_categories_to_items(category_model, category.categories, threads)
 
@@ -207,57 +210,22 @@ def filter_threads_queryset(user, categories, list_type, queryset):
 
 def filter_read_threads_queryset(user, categories, list_type, queryset):
     # grab cutoffs for categories
-    cutoff_date = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
+    cutoff_date = get_cutoff_date(user)
 
-    if cutoff_date < user.joined_on:
-        cutoff_date = user.joined_on
+    visible_posts = Post.objects.filter(posted_on__gt=cutoff_date)
+    visible_posts = exclude_invisible_posts(user, categories, visible_posts)
 
-    categories_dict = {}
-    for record in user.categoryread_set.filter(category__in=categories):
-        if record.last_read_on > cutoff_date:
-            categories_dict[record.category_id] = record.last_read_on
+    queryset = queryset.filter(id__in=visible_posts.values('thread'))
+
+    read_posts = visible_posts.filter(id__in=user.postread_set.values('post'))
 
     if list_type == 'new':
         # new threads have no entry in reads table
-        # AND were started after cutoff date
-        read_threads = user.threadread_set.filter(category__in=categories).values('thread_id')
-
-        condition = Q(last_post_on__lte=cutoff_date)
-        condition = condition | Q(id__in=read_threads)
-
-        if categories_dict:
-            for category_id, category_cutoff in categories_dict.items():
-                condition = condition | Q(
-                    category_id=category_id,
-                    last_post_on__lte=category_cutoff,
-                )
+        return queryset.exclude(id__in=read_posts.values('thread'))
 
-        return queryset.exclude(condition)
-    elif list_type == 'unread':
+    if list_type == 'unread':
         # unread threads were read in past but have new posts
-        # after cutoff date
-        read_threads = user.threadread_set.filter(
-            category__in=categories,
-            thread__last_post_on__gt=cutoff_date,
-            last_read_on__lt=F('thread__last_post_on'),
-        ).values('thread_id')
-
-        queryset = queryset.filter(id__in=read_threads)
-
-        # unread threads have last reply after read/cutoff date
-        if categories_dict:
-            conditions = None
-
-            for category_id, category_cutoff in categories_dict.items():
-                condition = Q(
-                    category_id=category_id,
-                    last_post_on__lte=category_cutoff,
-                )
-                if conditions:
-                    conditions = conditions | condition
-                else:
-                    conditions = condition
-
-            return queryset.exclude(conditions)
-        else:
-            return queryset
+        unread_posts = visible_posts.exclude(id__in=user.postread_set.values('post'))
+        queryset = queryset.filter(id__in=read_posts.values('thread'))
+        queryset = queryset.filter(id__in=unread_posts.values('thread'))
+        return queryset

+ 32 - 27
misago/threads/views/goto.py

@@ -1,18 +1,19 @@
 from math import ceil
 
 from django.core.exceptions import PermissionDenied
+from django.db.models import Q
 from django.shortcuts import get_object_or_404, redirect
 from django.utils.translation import ugettext as _
 from django.views import View
 
 from misago.conf import settings
+from misago.readtracker.dates import get_cutoff_date
 from misago.threads.permissions import exclude_invisible_posts
 from misago.threads.viewmodels import ForumThread, PrivateThread
 
 
 class GotoView(View):
     thread = None
-    read_aware = False
 
     def get(self, request, pk, slug, **kwargs):
         thread = self.get_thread(request, pk, slug).unwrap()
@@ -20,18 +21,18 @@ class GotoView(View):
 
         posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
 
-        target_post = self.get_target_post(thread, posts_queryset.order_by('id'), **kwargs)
+        target_post = self.get_target_post(request.user, thread, posts_queryset.order_by('id'), **kwargs)
         target_page = self.compute_post_page(target_post, posts_queryset)
 
         return self.get_redirect(thread, target_post, target_page)
 
     def get_thread(self, request, pk, slug):
-        return self.thread(request, pk, slug, read_aware=self.read_aware)
+        return self.thread(request, pk, slug)
 
     def test_permissions(self, request, thread):
         pass
 
-    def get_target_post(self, thread, posts_queryset):
+    def get_target_post(self, user, thread, posts_queryset):
         raise NotImplementedError("goto views should define their own get_target_post method")
 
     def compute_post_page(self, target_post, posts_queryset):
@@ -70,28 +71,38 @@ class GotoView(View):
 class ThreadGotoPostView(GotoView):
     thread = ForumThread
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
         return get_object_or_404(posts_queryset, pk=kwargs['post'])
 
 
 class ThreadGotoLastView(GotoView):
     thread = ForumThread
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
         return posts_queryset.order_by('id').last()
 
 
-class ThreadGotoNewView(GotoView):
-    thread = ForumThread
-    read_aware = True
+class GetFirstUnreadPostMixin(object):
+    def get_first_unread_post(self, user, posts_queryset):
+        if user.is_authenticated:
+            expired_posts = Q(posted_on__lt=get_cutoff_date(user))
+            read_posts = Q(id__in=user.postread_set.values('post'))
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
-        if thread.is_new:
-            return posts_queryset.filter(
-                posted_on__gt=thread.last_read_on,
+            first_unread = posts_queryset.exclude(
+                expired_posts | read_posts,
             ).order_by('id').first()
-        else:
-            return posts_queryset.order_by('id').last()
+
+            if first_unread:
+                return first_unread
+
+        return posts_queryset.order_by('id').last()
+
+
+class ThreadGotoNewView(GotoView, GetFirstUnreadPostMixin):
+    thread = ForumThread
+
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
+        return self.get_first_unread_post(user, posts_queryset)
 
 
 class ThreadGotoUnapprovedView(GotoView):
@@ -106,7 +117,7 @@ class ThreadGotoUnapprovedView(GotoView):
                 )
             )
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
         unapproved_post = posts_queryset.filter(
             is_unapproved=True,
         ).order_by('id').first()
@@ -119,25 +130,19 @@ class ThreadGotoUnapprovedView(GotoView):
 class PrivateThreadGotoPostView(GotoView):
     thread = PrivateThread
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
         return get_object_or_404(posts_queryset, pk=kwargs['post'])
 
 
 class PrivateThreadGotoLastView(GotoView):
     thread = PrivateThread
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
         return posts_queryset.order_by('id').last()
 
 
-class PrivateThreadGotoNewView(GotoView):
+class PrivateThreadGotoNewView(GotoView, GetFirstUnreadPostMixin):
     thread = PrivateThread
-    read_aware = True
 
-    def get_target_post(self, thread, posts_queryset, **kwargs):
-        if thread.is_new:
-            return posts_queryset.filter(
-                posted_on__gt=thread.last_read_on,
-            ).order_by('id').first()
-        else:
-            return posts_queryset.order_by('id').last()
+    def get_target_post(self, user, thread, posts_queryset, **kwargs):
+        return self.get_first_unread_post(user, posts_queryset)