From 828a6319ce309f46360b91f3bd755d2666f9619d Mon Sep 17 00:00:00 2001 From: rafalp Date: Tue, 27 Aug 2024 22:02:20 +0200 Subject: [PATCH] Cleanup tracker codebase a little, more tests --- misago/categories/components.py | 2 +- misago/readtracker/categories.py | 56 ------- ...ad_readcategory.py => 0005_new_tracker.py} | 52 +++---- .../migrations/0006_delete_postread.py | 16 -- misago/readtracker/models.py | 10 +- .../test_annotate_categories_read_time.py | 2 +- .../tests/test_annotate_threads_read_time.py | 2 +- .../tests/test_get_categories_unread_posts.py | 4 +- .../tests/test_get_threads_unread_posts.py | 137 ++++++++++++++++++ misago/readtracker/{threads.py => tracker.py} | 48 ++++++ misago/threads/views/generic.py | 2 +- misago/threads/views/list.py | 2 +- misago/threads/views/replies.py | 2 +- 13 files changed, 225 insertions(+), 110 deletions(-) delete mode 100644 misago/readtracker/categories.py rename misago/readtracker/migrations/{0005_readthread_readcategory.py => 0005_new_tracker.py} (83%) delete mode 100644 misago/readtracker/migrations/0006_delete_postread.py create mode 100644 misago/readtracker/tests/test_get_threads_unread_posts.py rename misago/readtracker/{threads.py => tracker.py} (68%) diff --git a/misago/categories/components.py b/misago/categories/components.py index ef612c8f2..5b943b3ba 100644 --- a/misago/categories/components.py +++ b/misago/categories/components.py @@ -6,7 +6,7 @@ from ..permissions.enums import CategoryPermission from ..permissions.proxy import UserPermissionsProxy -from ..readtracker.categories import ( +from ..readtracker.tracker import ( annotate_categories_read_time, get_categories_unread_posts, ) diff --git a/misago/readtracker/categories.py b/misago/readtracker/categories.py deleted file mode 100644 index 9391780fc..000000000 --- a/misago/readtracker/categories.py +++ /dev/null @@ -1,56 +0,0 @@ -from datetime import datetime -from typing import Iterable - -from django.http import HttpRequest -from django.db.models import OuterRef - -from ..categories.models import Category -from .readtime import get_default_read_time -from .models import ReadCategory - - -def annotate_categories_read_time(user, queryset): - if user.is_anonymous: - return queryset - - return queryset.annotate( - read_time=ReadCategory.objects.filter( - user=user, - category=OuterRef("id"), - ).values("read_time"), - ) - - -def get_categories_unread_posts( - request: HttpRequest, - categories: Iterable[Category], -) -> dict[int, bool]: - if not categories: - return {} - - if request.user.is_anonymous: - return {category.id: False for category in categories} - - default_read_time = get_default_read_time(request.settings, request.user) - - read_data = {} - for category in categories: - read_data[category.id] = get_category_unread_status(category, default_read_time) - - return read_data - - -def get_category_unread_status( - category: Category, - default_read_time: datetime, -) -> bool: - if not category.last_post_on: - return False - - if category.last_post_on < default_read_time: - return False - - if not category.read_time: - return True - - return category.last_post_on > category.read_time diff --git a/misago/readtracker/migrations/0005_readthread_readcategory.py b/misago/readtracker/migrations/0005_new_tracker.py similarity index 83% rename from misago/readtracker/migrations/0005_readthread_readcategory.py rename to misago/readtracker/migrations/0005_new_tracker.py index 89158edce..39d19f079 100644 --- a/misago/readtracker/migrations/0005_readthread_readcategory.py +++ b/misago/readtracker/migrations/0005_new_tracker.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-08-24 11:29 +# Generated by Django 4.2.10 on 2024-08-27 16:36 from django.conf import settings from django.db import migrations, models @@ -9,15 +9,15 @@ class Migration(migrations.Migration): dependencies = [ + ("misago_threads", "0014_plugin_data"), ("misago_categories", "0013_new_behaviors"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("misago_threads", "0014_plugin_data"), ("misago_readtracker", "0004_auto_20171015_2010"), ] operations = [ migrations.CreateModel( - name="ReadThread", + name="ReadCategory", fields=[ ( "id", @@ -36,13 +36,6 @@ class Migration(migrations.Migration): to="misago_categories.category", ), ), - ( - "thread", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="misago_threads.thread", - ), - ), ( "user", models.ForeignKey( @@ -51,16 +44,9 @@ class Migration(migrations.Migration): ), ), ], - options={ - "indexes": [ - models.Index( - fields=["user", "thread"], name="misago_read_user_id_537fc1_idx" - ) - ], - }, ), migrations.CreateModel( - name="ReadCategory", + name="ReadThread", fields=[ ( "id", @@ -79,6 +65,13 @@ class Migration(migrations.Migration): to="misago_categories.category", ), ), + ( + "thread", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="misago_threads.thread", + ), + ), ( "user", models.ForeignKey( @@ -87,13 +80,20 @@ class Migration(migrations.Migration): ), ), ], - options={ - "indexes": [ - models.Index( - fields=["user", "category"], - name="misago_read_user_id_203f85_idx", - ) - ], - }, + ), + migrations.DeleteModel( + name="PostRead", + ), + migrations.AddConstraint( + model_name="readthread", + constraint=models.UniqueConstraint( + fields=("user", "thread"), name="uniq_user_thread" + ), + ), + migrations.AddConstraint( + model_name="readcategory", + constraint=models.UniqueConstraint( + fields=("user", "category"), name="uniq_user_category" + ), ), ] diff --git a/misago/readtracker/migrations/0006_delete_postread.py b/misago/readtracker/migrations/0006_delete_postread.py deleted file mode 100644 index e59185309..000000000 --- a/misago/readtracker/migrations/0006_delete_postread.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.10 on 2024-08-24 11:41 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("misago_readtracker", "0005_readthread_readcategory"), - ] - - operations = [ - migrations.DeleteModel( - name="PostRead", - ), - ] diff --git a/misago/readtracker/models.py b/misago/readtracker/models.py index 5ffe2204e..3dbbfdf1f 100644 --- a/misago/readtracker/models.py +++ b/misago/readtracker/models.py @@ -9,8 +9,10 @@ class ReadCategory(models.Model): read_time = models.DateTimeField(default=timezone.now) class Meta: - indexes = [ - models.Index(fields=["user", "category"]), + constraints = [ + models.UniqueConstraint( + fields=["user", "category"], name="uniq_user_category" + ), ] @@ -21,6 +23,6 @@ class ReadThread(models.Model): read_time = models.DateTimeField(default=timezone.now) class Meta: - indexes = [ - models.Index(fields=["user", "thread"]), + constraints = [ + models.UniqueConstraint(fields=["user", "thread"], name="uniq_user_thread"), ] diff --git a/misago/readtracker/tests/test_annotate_categories_read_time.py b/misago/readtracker/tests/test_annotate_categories_read_time.py index 963c92a70..01a0bf3a6 100644 --- a/misago/readtracker/tests/test_annotate_categories_read_time.py +++ b/misago/readtracker/tests/test_annotate_categories_read_time.py @@ -1,8 +1,8 @@ from django.utils import timezone from ...categories.models import Category -from ..categories import annotate_categories_read_time from ..models import ReadCategory +from ..tracker import annotate_categories_read_time def test_annotate_categories_read_time_is_noop_for_anonymous_user(db, anonymous_user): diff --git a/misago/readtracker/tests/test_annotate_threads_read_time.py b/misago/readtracker/tests/test_annotate_threads_read_time.py index 3fe157fd2..98a44d714 100644 --- a/misago/readtracker/tests/test_annotate_threads_read_time.py +++ b/misago/readtracker/tests/test_annotate_threads_read_time.py @@ -1,8 +1,8 @@ from django.utils import timezone from ...threads.models import Thread -from ..threads import annotate_threads_read_time from ..models import ReadCategory, ReadThread +from ..tracker import annotate_threads_read_time def test_annotate_threads_read_time_is_noop_for_anonymous_user(anonymous_user, thread): diff --git a/misago/readtracker/tests/test_get_categories_unread_posts.py b/misago/readtracker/tests/test_get_categories_unread_posts.py index ec390ab5a..870641071 100644 --- a/misago/readtracker/tests/test_get_categories_unread_posts.py +++ b/misago/readtracker/tests/test_get_categories_unread_posts.py @@ -4,11 +4,11 @@ from django.utils import timezone from ...categories.models import Category -from ..categories import annotate_categories_read_time, get_categories_unread_posts from ..models import ReadCategory +from ..tracker import annotate_categories_read_time, get_categories_unread_posts -def test_get_categories_unread_posts_returns_false_anonymous_user( +def test_get_categories_unread_posts_returns_false_for_anonymous_user( dynamic_settings, default_category, anonymous_user ): default_category.last_post_on = timezone.now() diff --git a/misago/readtracker/tests/test_get_threads_unread_posts.py b/misago/readtracker/tests/test_get_threads_unread_posts.py new file mode 100644 index 000000000..6edaa0f53 --- /dev/null +++ b/misago/readtracker/tests/test_get_threads_unread_posts.py @@ -0,0 +1,137 @@ +from datetime import timedelta +from unittest.mock import Mock + +from django.utils import timezone + +from ..models import ReadCategory, ReadThread +from ..tracker import annotate_threads_read_time, get_threads_unread_posts + + +def test_get_threads_unread_posts_returns_false_for_anonymous_user( + dynamic_settings, default_category, thread, anonymous_user +): + request = Mock(settings=dynamic_settings, user=anonymous_user) + queryset = annotate_threads_read_time(anonymous_user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert not unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_true_for_unread_thread( + dynamic_settings, default_category, thread, user +): + user.joined_on -= timedelta(minutes=30) + user.save() + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_false_for_unread_thread_older_than_user( + dynamic_settings, default_category, thread, user +): + thread.last_post_on -= timedelta(minutes=30) + thread.save() + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert not unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_false_for_old_unread_thread( + dynamic_settings, default_category, thread, user +): + user.joined_on = user.joined_on.replace(year=2010) + user.save() + + thread.last_post_on = thread.last_post_on.replace(year=2012) + thread.save() + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert not unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_false_for_read_thread( + dynamic_settings, default_category, thread, user +): + user.joined_on -= timedelta(minutes=30) + user.save() + + ReadThread.objects.create( + user=user, + category=default_category, + thread=thread, + read_time=timezone.now(), + ) + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert not unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_false_for_unread_thread_in_read_category( + dynamic_settings, default_category, thread, user +): + user.joined_on -= timedelta(minutes=30) + user.save() + + ReadCategory.objects.create( + user=user, + category=default_category, + read_time=timezone.now(), + ) + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert not unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_true_for_read_thread_with_unread_reply( + dynamic_settings, default_category, thread, user +): + user.joined_on -= timedelta(minutes=30) + user.save() + + ReadThread.objects.create( + user=user, + category=default_category, + thread=thread, + read_time=timezone.now() - timedelta(minutes=5), + ) + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert unread_posts[thread.id] + + +def test_get_threads_unread_posts_returns_true_for_read_thread_in_read_category_with_unread_reply( + dynamic_settings, default_category, thread, user +): + user.joined_on -= timedelta(minutes=30) + user.save() + + ReadCategory.objects.create( + user=user, + category=default_category, + read_time=timezone.now() - timedelta(minutes=5), + ) + + request = Mock(settings=dynamic_settings, user=user) + queryset = annotate_threads_read_time(user, default_category.thread_set) + unread_posts = get_threads_unread_posts(request, queryset.all()) + + assert unread_posts[thread.id] diff --git a/misago/readtracker/threads.py b/misago/readtracker/tracker.py similarity index 68% rename from misago/readtracker/threads.py rename to misago/readtracker/tracker.py index 6e0410423..777cd458f 100644 --- a/misago/readtracker/threads.py +++ b/misago/readtracker/tracker.py @@ -4,11 +4,59 @@ from django.http import HttpRequest from django.db.models import OuterRef +from ..categories.models import Category from ..threads.models import Post, Thread from .readtime import get_default_read_time from .models import ReadCategory, ReadThread +def annotate_categories_read_time(user, queryset): + if user.is_anonymous: + return queryset + + return queryset.annotate( + read_time=ReadCategory.objects.filter( + user=user, + category=OuterRef("id"), + ).values("read_time"), + ) + + +def get_categories_unread_posts( + request: HttpRequest, + categories: Iterable[Category], +) -> dict[int, bool]: + if not categories: + return {} + + if request.user.is_anonymous: + return {category.id: False for category in categories} + + default_read_time = get_default_read_time(request.settings, request.user) + + read_data = {} + for category in categories: + read_data[category.id] = get_category_unread_status(category, default_read_time) + + return read_data + + +def get_category_unread_status( + category: Category, + default_read_time: datetime, +) -> bool: + if not category.last_post_on: + return False + + if category.last_post_on < default_read_time: + return False + + if not category.read_time: + return True + + return category.last_post_on > category.read_time + + def annotate_threads_read_time(user, queryset): if user.is_anonymous: return queryset diff --git a/misago/threads/views/generic.py b/misago/threads/views/generic.py index a335fd181..6c6c9a4f2 100644 --- a/misago/threads/views/generic.py +++ b/misago/threads/views/generic.py @@ -16,7 +16,7 @@ check_see_thread_permission, filter_thread_posts_queryset, ) -from ...readtracker.threads import annotate_threads_read_time +from ...readtracker.tracker import annotate_threads_read_time from ..models import Post, Thread from ..paginator import ThreadRepliesPaginator diff --git a/misago/threads/views/list.py b/misago/threads/views/list.py index 3bbc81268..2d8481af7 100644 --- a/misago/threads/views/list.py +++ b/misago/threads/views/list.py @@ -51,7 +51,7 @@ ThreadsQuerysetFilter, check_start_thread_in_category_permission, ) -from ...readtracker.threads import ( +from ...readtracker.tracker import ( annotate_threads_read_time, get_threads_unread_posts, ) diff --git a/misago/threads/views/replies.py b/misago/threads/views/replies.py index a3d1910c6..e93ca763c 100644 --- a/misago/threads/views/replies.py +++ b/misago/threads/views/replies.py @@ -8,7 +8,7 @@ from django.views import View from ...core.exceptions import OutdatedSlug -from ...readtracker.threads import get_thread_posts_unread_status +from ...readtracker.tracker import get_thread_posts_unread_status from ..hooks import ( get_private_thread_replies_page_context_data_hook, get_private_thread_replies_page_posts_queryset_hook,