diff --git a/CHANGELOG.md b/CHANGELOG.md index d52d8a35..faa599ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,23 @@ # Change Log -## [] - YYYY-MM-DD - +## [2.8.0] - 2020-09-26 + + * Fixes issue #106, which is about computing the number of nested comments + for every comment at every level down the tree. The fix consists of + adding a new field called 'nested_count' to the XtdComment model. Its + value represents the number of threaded comments under itself. A new + management command, 'initialize_nested_count', can be used to update the + value of the field, the command is idempotent. Two new migrations have + been added: migration 0007 adds the new field, and migration 0008 calls + the 'initialize_nested_count' command to populate the nested_count new + field with correct values. * Fixes issue #215 about running the tests with Django 3.1 and Python 3.8. ## [2.7.2] - 2020-09-08 * Fixes issue #208, about the JavaScript plugin not displaying the like and dislike buttons and the reply link when django-comments-xtd is setup to - allow posting comments only to registered users (who_can_post: "users"). + allow posting comments only to registered users (who_can_post: "users"). * Fixes issue #212, about missing i18n JavaScript catalog files for Dutch, German and Russian. @@ -22,9 +31,9 @@ ## [2.7.0] - 2020-08-09 - * Enhancement, closing issue #155 (and #170), on how to post comments via + * Enhancement, closing issue #155 (and #170), on how to post comments via the web API. Up until version 2.6.2 posting comments required the fields - timestamp, security_hash and honeypot. As of 2.7.0 there is support to + timestamp, security_hash and honeypot. As of 2.7.0 there is support to allow Django REST Framework authentication classes: WriteCommentSerializer send the signal should_request_be_authorize that enables posting comments. Read the documentation about the web API. diff --git a/django_comments_xtd/admin.py b/django_comments_xtd/admin.py index 7d23b48d..00f401bd 100644 --- a/django_comments_xtd/admin.py +++ b/django_comments_xtd/admin.py @@ -9,9 +9,9 @@ class XtdCommentsAdmin(CommentsAdmin): - list_display = ('thread_level', 'cid', 'name', 'content_type', 'object_pk', - 'ip_address', 'submit_date', 'followup', 'is_public', - 'is_removed') + list_display = ('cid', 'thread_level', 'nested_count', 'name', + 'content_type', 'object_pk', 'ip_address', 'submit_date', + 'followup', 'is_public', 'is_removed') list_display_links = ('cid',) list_filter = ('content_type', 'is_public', 'is_removed', 'followup') fieldsets = ( diff --git a/django_comments_xtd/management/commands/initialize_nested_count.py b/django_comments_xtd/management/commands/initialize_nested_count.py new file mode 100644 index 00000000..d423d153 --- /dev/null +++ b/django_comments_xtd/management/commands/initialize_nested_count.py @@ -0,0 +1,48 @@ +from django.db.utils import ConnectionDoesNotExist +from django.core.management.base import BaseCommand + +from django_comments_xtd.models import XtdComment + + +class Command(BaseCommand): + help = "Initialize the nested_count field for all the comments in the DB." + + def add_arguments(self, parser): + parser.add_argument('using', nargs='*', type=str) + + def initialize_nested_count(self, using): + # Control break. + active_thread_id = -1 + parents = {} + + qs = XtdComment.objects.using(using).order_by('thread_id', '-order') + + for comment in qs: + # Clean up parents when there is a control break. + if comment.thread_id != active_thread_id: + parents = {} + active_thread_id = comment.thread_id + + nested_count = parents.get(comment.comment_ptr_id, 0) + parents.setdefault(comment.parent_id, 0) + if nested_count > 0: + parents[comment.parent_id] += 1 + nested_count + else: + parents[comment.parent_id] += 1 + comment.nested_count = nested_count + comment.save() + + return qs.count() + + def handle(self, *args, **options): + total = 0 + using = options['using'] or ['default'] + + for db_conn in using: + try: + total += self.initialize_nested_count(db_conn) + except ConnectionDoesNotExist: + self.stdout.write("DB connection '%s' does not exist." % + db_conn) + continue + self.stdout.write("Updated %d XtdComment object(s)." % total) diff --git a/django_comments_xtd/migrations/0007_xtdcomment_nested_count.py b/django_comments_xtd/migrations/0007_xtdcomment_nested_count.py new file mode 100644 index 00000000..70295371 --- /dev/null +++ b/django_comments_xtd/migrations/0007_xtdcomment_nested_count.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.1 on 2020-09-12 20:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_comments_xtd', '0006_auto_20181204_0948'), + ] + + operations = [ + migrations.AddField( + model_name='xtdcomment', + name='nested_count', + field=models.IntegerField(db_index=True, default=0), + ), + ] diff --git a/django_comments_xtd/migrations/0008_auto_20200920_2037.py b/django_comments_xtd/migrations/0008_auto_20200920_2037.py new file mode 100644 index 00000000..d4658398 --- /dev/null +++ b/django_comments_xtd/migrations/0008_auto_20200920_2037.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.1 on 2020-09-20 18:37 +from django.core.management import call_command +from django.db import migrations + + +def populate_nested_count(*args): + call_command('initialize_nested_count') + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_comments_xtd', '0007_xtdcomment_nested_count'), + ] + + operations = [ + migrations.RunPython(populate_nested_count, + reverse_code=migrations.RunPython.noop) + ] diff --git a/django_comments_xtd/models.py b/django_comments_xtd/models.py index 2deee03b..b6df5afb 100644 --- a/django_comments_xtd/models.py +++ b/django_comments_xtd/models.py @@ -35,7 +35,6 @@ def __str__(self): class XtdCommentManager(CommentManager): - def for_app_models(self, *args, **kwargs): """Return XtdComments for pairs "app.model" given in args""" content_types = [] @@ -67,6 +66,7 @@ class XtdComment(Comment): order = models.IntegerField(default=1, db_index=True) followup = models.BooleanField(blank=True, default=False, help_text=_("Notify follow-up comments")) + nested_count = models.IntegerField(default=0, db_index=True) objects = XtdCommentManager() def save(self, *args, **kwargs): @@ -99,14 +99,17 @@ def _calculate_thread_data(self): order__gt=parent.order) if qc_ge_level.count(): min_order = qc_ge_level.aggregate(Min('order'))['order__min'] - XtdComment.objects.filter(thread_id=parent.thread_id, - order__gte=min_order)\ - .update(order=F('order') + 1) + qc_eq_thread.filter(order__gte=min_order)\ + .update(order=F('order') + 1) self.order = min_order else: max_order = qc_eq_thread.aggregate(Max('order'))['order__max'] self.order = max_order + 1 + qc_eq_thread.filter(Q(pk=parent.pk) | Q(level__lt=parent.level, + order__lt=parent.order))\ + .update(nested_count=F('nested_count') + 1) + def get_reply_url(self): return reverse("comments-xtd-reply", kwargs={"cid": self.pk}) @@ -206,8 +209,8 @@ def get_comment_dict(obj): return dic_list -def publish_or_unpublish_nested_comments(comment_id, are_public=False): - qs = get_model().objects.filter(~Q(pk=comment_id), parent_id=comment_id) +def publish_or_unpublish_nested_comments(comment, are_public=False): + qs = get_model().objects.filter(~Q(pk=comment.id), parent_id=comment.id) nested = [cm.id for cm in qs] qs.update(is_public=are_public) while len(nested): @@ -215,12 +218,24 @@ def publish_or_unpublish_nested_comments(comment_id, are_public=False): qs = XtdComment.objects.filter(~Q(pk=cm_id), parent_id=cm_id) nested.extend([cm.id for cm in qs]) qs.update(is_public=are_public) + # Update nested_count in parents comments in the same thread. + # The comment.nested_count doesn't change because the comment's is_public + # attribute is not changing, only its nested comments change, and it will + # help to re-populate nested_count should it be published again. + if are_public: + op = F('nested_count') + comment.nested_count + else: + op = F('nested_count') - comment.nested_count + XtdComment.objects.filter(thread_id=comment.thread_id, + level__lt=comment.level, + order__lt=comment.order)\ + .update(nested_count=op) def publish_or_unpublish_on_pre_save(sender, instance, raw, using, **kwargs): if not raw and instance and instance.id: are_public = (not instance.is_removed) and instance.is_public - publish_or_unpublish_nested_comments(instance.id, are_public=are_public) + publish_or_unpublish_nested_comments(instance, are_public=are_public) # ---------------------------------------------------------------------- diff --git a/django_comments_xtd/tests/test_cmd_initialize_nested_count.py b/django_comments_xtd/tests/test_cmd_initialize_nested_count.py new file mode 100644 index 00000000..a8109f73 --- /dev/null +++ b/django_comments_xtd/tests/test_cmd_initialize_nested_count.py @@ -0,0 +1,59 @@ +from io import StringIO +from django.core.management import call_command +from django.test import TestCase + +from django_comments_xtd.models import XtdComment +from django_comments_xtd.tests.models import Article +from django_comments_xtd.tests.test_models import ( + thread_test_step_1, thread_test_step_2, thread_test_step_3, + thread_test_step_4, thread_test_step_5 +) + + +class InitializeNesteCoundCmdTest(TestCase): + def setUp(self): + self.article_1 = Article.objects.create( + title="September", slug="september", body="During September...") + thread_test_step_1(self.article_1) + thread_test_step_2(self.article_1) + thread_test_step_3(self.article_1) + thread_test_step_4(self.article_1) + thread_test_step_5(self.article_1) + self.check_nested_count() + + def check_nested_count(self): + ( # content -> cmt.id thread_id parent_id level order nested + self.c1, # -> 1 1 1 0 1 4 + self.c3, # -> 3 1 1 1 2 1 + self.c8, # -> 8 1 3 2 3 0 + self.c4, # -> 4 1 1 1 4 1 + self.c7, # -> 7 1 4 2 5 0 + self.c2, # -> 2 2 2 0 1 2 + self.c5, # -> 5 2 2 1 2 1 + self.c6, # -> 6 2 5 2 3 0 + self.c9 # -> 9 9 9 0 1 0 + ) = XtdComment.objects.all() + self.assertEqual(self.c1.nested_count, 4) + self.assertEqual(self.c3.nested_count, 1) + self.assertEqual(self.c8.nested_count, 0) + self.assertEqual(self.c4.nested_count, 1) + self.assertEqual(self.c7.nested_count, 0) + self.assertEqual(self.c2.nested_count, 2) + self.assertEqual(self.c5.nested_count, 1) + self.assertEqual(self.c6.nested_count, 0) + self.assertEqual(self.c9.nested_count, 0) + + def test_calling_command_computes_nested_count(self): + # Set all comments nested_count field to 0. + XtdComment.objects.update(nested_count=0) + out = StringIO() + call_command('initialize_nested_count', stdout=out) + self.assertIn("Updated 9 XtdComment object(s).", out.getvalue()) + self.check_nested_count() + + def test_command_is_idempotent(self): + out = StringIO() + call_command('initialize_nested_count', stdout=out) + call_command('initialize_nested_count', stdout=out) + self.assertIn("Updated 9 XtdComment object(s).", out.getvalue()) + self.check_nested_count() diff --git a/django_comments_xtd/tests/test_models.py b/django_comments_xtd/tests/test_models.py index 67cbdff5..bc56d786 100644 --- a/django_comments_xtd/tests/test_models.py +++ b/django_comments_xtd/tests/test_models.py @@ -218,47 +218,52 @@ class BaseThreadStep1TestCase(ArticleBaseTestCase): def setUp(self): super(BaseThreadStep1TestCase, self).setUp() thread_test_step_1(self.article_1) - ( # content -> cmt.id thread_id parent_id level order - self.c1, # -> 1 1 1 0 1 - self.c2 # -> 2 2 2 0 1 + ( # content -> cmt.id thread_id parent_id level order nested + self.c1, # -> 1 1 1 0 1 0 + self.c2 # -> 2 2 2 0 1 0 ) = XtdComment.objects.all() def test_threaded_comments_step_1_level_0(self): # comment 1 self.assertTrue(self.c1.parent_id == 1 and self.c1.thread_id == 1) self.assertTrue(self.c1.level == 0 and self.c1.order == 1) + self.assertEqual(self.c1.nested_count, 0) # comment 2 self.assertTrue(self.c2.parent_id == 2 and self.c2.thread_id == 2) self.assertTrue(self.c2.level == 0 and self.c2.order == 1) - + self.assertEqual(self.c2.nested_count, 0) class ThreadStep2TestCase(ArticleBaseTestCase): def setUp(self): super(ThreadStep2TestCase, self).setUp() thread_test_step_1(self.article_1) thread_test_step_2(self.article_1) - ( # content -> cmt.id thread_id parent_id level order - self.c1, # -> 1 1 1 0 1 - self.c3, # -> 3 1 1 1 2 - self.c4, # -> 4 1 1 1 3 - self.c2 # -> 2 2 2 0 1 + ( # content -> cmt.id thread_id parent_id level order nested + self.c1, # -> 1 1 1 0 1 2 + self.c3, # -> 3 1 1 1 2 0 + self.c4, # -> 4 1 1 1 3 0 + self.c2 # -> 2 2 2 0 1 0 ) = XtdComment.objects.all() def test_threaded_comments_step_2_level_0(self): # comment 1 self.assertTrue(self.c1.parent_id == 1 and self.c1.thread_id == 1) self.assertTrue(self.c1.level == 0 and self.c1.order == 1) + self.assertEqual(self.c1.nested_count, 2) # comment 2 self.assertTrue(self.c2.parent_id == 2 and self.c2.thread_id == 2) self.assertTrue(self.c2.level == 0 and self.c2.order == 1) + self.assertEqual(self.c2.nested_count, 0) def test_threaded_comments_step_2_level_1(self): # comment 3 self.assertTrue(self.c3.parent_id == 1 and self.c3.thread_id == 1) self.assertTrue(self.c3.level == 1 and self.c3.order == 2) + self.assertEqual(self.c3.nested_count, 0) # comment 4 self.assertTrue(self.c4.parent_id == 1 and self.c4.thread_id == 1) self.assertTrue(self.c4.level == 1 and self.c4.order == 3) + self.assertEqual(self.c4.nested_count, 0) class ThreadStep3TestCase(ArticleBaseTestCase): @@ -268,32 +273,37 @@ def setUp(self): thread_test_step_2(self.article_1) thread_test_step_3(self.article_1) - ( # -> content: cmt.id thread_id parent_id level order - self.c1, # -> 1 1 1 0 1 - self.c3, # -> 3 1 1 1 2 - self.c4, # -> 4 1 1 1 3 - self.c2, # -> 2 2 2 0 1 - self.c5 # -> 5 2 2 1 2 + ( # -> content: cmt.id thread_id parent_id level order nested + self.c1, # -> 1 1 1 0 1 2 + self.c3, # -> 3 1 1 1 2 0 + self.c4, # -> 4 1 1 1 3 0 + self.c2, # -> 2 2 2 0 1 1 + self.c5 # -> 5 2 2 1 2 0 ) = XtdComment.objects.all() def test_threaded_comments_step_3_level_0(self): # comment 1 self.assertTrue(self.c1.parent_id == 1 and self.c1.thread_id == 1) self.assertTrue(self.c1.level == 0 and self.c1.order == 1) + self.assertEqual(self.c1.nested_count, 2) # comment 2 self.assertTrue(self.c2.parent_id == 2 and self.c2.thread_id == 2) self.assertTrue(self.c2.level == 0 and self.c2.order == 1) + self.assertEqual(self.c2.nested_count, 1) def test_threaded_comments_step_3_level_1(self): # comment 3 self.assertTrue(self.c3.parent_id == 1 and self.c3.thread_id == 1) self.assertTrue(self.c3.level == 1 and self.c3.order == 2) + self.assertEqual(self.c3.nested_count, 0) # comment 4 self.assertTrue(self.c4.parent_id == 1 and self.c4.thread_id == 1) self.assertTrue(self.c4.level == 1 and self.c4.order == 3) + self.assertEqual(self.c4.nested_count, 0) # comment 5 self.assertTrue(self.c5.parent_id == 2 and self.c5.thread_id == 2) self.assertTrue(self.c5.level == 1 and self.c5.order == 2) + self.assertEqual(self.c5.nested_count, 0) class ThreadStep4TestCase(ArticleBaseTestCase): @@ -304,42 +314,49 @@ def setUp(self): thread_test_step_3(self.article_1) thread_test_step_4(self.article_1) - ( # content -> cmt.id thread_id parent_id level order - self.c1, # -> 1 1 1 0 1 - self.c3, # -> 3 1 1 1 2 - self.c4, # -> 4 1 1 1 3 - self.c7, # -> 7 1 4 2 4 - self.c2, # -> 2 2 2 0 1 - self.c5, # -> 5 2 2 1 2 - self.c6 # -> 6 2 5 2 3 + ( # content -> cmt.id thread_id parent_id level order nested + self.c1, # -> 1 1 1 0 1 3 + self.c3, # -> 3 1 1 1 2 0 + self.c4, # -> 4 1 1 1 3 1 + self.c7, # -> 7 1 4 2 4 0 + self.c2, # -> 2 2 2 0 1 2 + self.c5, # -> 5 2 2 1 2 1 + self.c6 # -> 6 2 5 2 3 0 ) = XtdComment.objects.all() def test_threaded_comments_step_4_level_0(self): # comment 1 self.assertTrue(self.c1.parent_id == 1 and self.c1.thread_id == 1) self.assertTrue(self.c1.level == 0 and self.c1.order == 1) + self.assertEqual(self.c1.nested_count, 3) # comment 2 self.assertTrue(self.c2.parent_id == 2 and self.c2.thread_id == 2) self.assertTrue(self.c2.level == 0 and self.c2.order == 1) + self.assertEqual(self.c2.nested_count, 2) def test_threaded_comments_step_4_level_1(self): # comment 3 self.assertTrue(self.c3.parent_id == 1 and self.c3.thread_id == 1) self.assertTrue(self.c3.level == 1 and self.c3.order == 2) + self.assertEqual(self.c3.nested_count, 0) # comment 4 self.assertTrue(self.c4.parent_id == 1 and self.c4.thread_id == 1) self.assertTrue(self.c4.level == 1 and self.c4.order == 3) + self.assertEqual(self.c4.nested_count, 1) # comment 5 self.assertTrue(self.c5.parent_id == 2 and self.c5.thread_id == 2) self.assertTrue(self.c5.level == 1 and self.c5.order == 2) + self.assertEqual(self.c5.nested_count, 1) def test_threaded_comments_step_4_level_2(self): # comment 6 self.assertTrue(self.c6.parent_id == 5 and self.c6.thread_id == 2) self.assertTrue(self.c6.level == 2 and self.c6.order == 3) + self.assertEqual(self.c6.nested_count, 0) # comment 7 self.assertTrue(self.c7.parent_id == 4 and self.c7.thread_id == 1) self.assertTrue(self.c7.level == 2 and self.c7.order == 4) + self.assertEqual(self.c7.nested_count, 0) class ThreadStep5TestCase(ArticleBaseTestCase): @@ -351,50 +368,59 @@ def setUp(self): thread_test_step_4(self.article_1) thread_test_step_5(self.article_1) - ( # content -> cmt.id thread_id parent_id level order - self.c1, # -> 1 1 1 0 1 - self.c3, # -> 3 1 1 1 2 - self.c8, # -> 8 1 3 2 3 - self.c4, # -> 4 1 1 1 4 - self.c7, # -> 7 1 4 2 5 - self.c2, # -> 2 2 2 0 1 - self.c5, # -> 5 2 2 1 2 - self.c6, # -> 6 2 5 2 3 - self.c9 # -> 9 9 9 0 1 + ( # content -> cmt.id thread_id parent_id level order nested + self.c1, # -> 1 1 1 0 1 4 + self.c3, # -> 3 1 1 1 2 1 + self.c8, # -> 8 1 3 2 3 0 + self.c4, # -> 4 1 1 1 4 1 + self.c7, # -> 7 1 4 2 5 0 + self.c2, # -> 2 2 2 0 1 2 + self.c5, # -> 5 2 2 1 2 1 + self.c6, # -> 6 2 5 2 3 0 + self.c9 # -> 9 9 9 0 1 0 ) = XtdComment.objects.all() def test_threaded_comments_step_5_level_0(self): # comment 1 self.assertTrue(self.c1.parent_id == 1 and self.c1.thread_id == 1) self.assertTrue(self.c1.level == 0 and self.c1.order == 1) + self.assertEqual(self.c1.nested_count, 4) # comment 2 self.assertTrue(self.c2.parent_id == 2 and self.c2.thread_id == 2) self.assertTrue(self.c2.level == 0 and self.c2.order == 1) + self.assertEqual(self.c2.nested_count, 2) # comment 9 self.assertTrue(self.c9.parent_id == 9 and self.c9.thread_id == 9) self.assertTrue(self.c9.level == 0 and self.c9.order == 1) + self.assertEqual(self.c9.nested_count, 0) def test_threaded_comments_step_5_level_1(self): # comment 3 self.assertTrue(self.c3.parent_id == 1 and self.c3.thread_id == 1) self.assertTrue(self.c3.level == 1 and self.c3.order == 2) + self.assertEqual(self.c3.nested_count, 1) # comment 4 self.assertTrue(self.c4.parent_id == 1 and self.c4.thread_id == 1) self.assertTrue(self.c4.level == 1 and self.c4.order == 4) # changed + self.assertEqual(self.c4.nested_count, 1) # comment 5 self.assertTrue(self.c5.parent_id == 2 and self.c5.thread_id == 2) self.assertTrue(self.c5.level == 1 and self.c5.order == 2) + self.assertEqual(self.c5.nested_count, 1) def test_threaded_comments_step_5_level_2(self): # comment 6 self.assertTrue(self.c6.parent_id == 5 and self.c6.thread_id == 2) self.assertTrue(self.c6.level == 2 and self.c6.order == 3) - # comment 7 + self.assertEqual(self.c6.nested_count, 0) + # comment 7 self.assertTrue(self.c7.parent_id == 4 and self.c7.thread_id == 1) self.assertTrue(self.c7.level == 2 and self.c7.order == 5) # changed + self.assertEqual(self.c7.nested_count, 0) # comment 8 self.assertTrue(self.c8.parent_id == 3 and self.c8.thread_id == 1) self.assertTrue(self.c8.level == 2 and self.c8.order == 3) + self.assertEqual(self.c8.nested_count, 0) def test_exceed_max_thread_level_raises_exception(self): article_ct = ContentType.objects.get(app_label="tests", model="article") @@ -408,6 +434,19 @@ def test_exceed_max_thread_level_raises_exception(self): submit_date=datetime.now(), parent_id=8) # already max thread level + def test_removing_c4_withdraws_c7_and_updates_nested_count(self): + cm4 = XtdComment.objects.get(pk=4) + self.assertEqual(cm4.nested_count, 1) + cm1 = XtdComment.objects.get(pk=1) + self.assertEqual(cm1.nested_count, 4) + # Remove comment 4, save, and check again. + cm4.is_removed = True + cm4.save() + cm4 = XtdComment.objects.get(pk=4) + self.assertEqual(cm4.nested_count, 1) + cm1 = XtdComment.objects.get(pk=1) + self.assertEqual(cm1.nested_count, 3) + def add_comment_to_diary_entry(diary_entry): diary_ct = ContentType.objects.get(app_label="tests", model="diary") @@ -418,20 +457,12 @@ def add_comment_to_diary_entry(diary_entry): site=site, comment="cmt to day in diary", submit_date=datetime.now()) - - + + class DiaryBaseTestCase(DjangoTestCase): def setUp(self): self.day_in_diary = Diary.objects.create(body="About Today...") - add_comment_to_diary_entry(self.day_in_diary) - # diary_ct = ContentType.objects.get(app_label="tests", model="diary") - # site = Site.objects.get(pk=1) - # XtdComment.objects.create(content_type=diary_ct, - # object_pk=self.day_in_diary.id, - # content_object=self.day_in_diary, - # site=site, - # comment="cmt to day in diary", - # submit_date=datetime.now()) + add_comment_to_diary_entry(self.day_in_diary) def test_max_thread_level_by_app_model(self): diary_ct = ContentType.objects.get(app_label="tests", model="diary") @@ -447,7 +478,7 @@ def test_max_thread_level_by_app_model(self): class PublishOrUnpublishNestedComments_1_TestCase(ArticleBaseTestCase): - # Add a threaded comment structure (c1, c2, c3) and verify that + # Add a threaded comment structure (c1, c2, c3) and verify that # removing c1 unpublishes c3. def setUp(self): @@ -457,11 +488,11 @@ def setUp(self): # # These two lines create the following comments: # - # ( # content -> cmt.id thread_id parent_id level order - # cm1, # -> 1 1 1 0 1 - # cm3, # -> 3 1 1 1 2 - # cm4, # -> 4 1 1 1 3 - # cm2, # -> 2 2 2 0 1 + # ( # content -> cmt.id thread_id parent_id level order nested + # cm1, # -> 1 1 1 0 1 2 + # cm3, # -> 3 1 1 1 2 0 + # cm4, # -> 4 1 1 1 3 0 + # cm2, # -> 2 2 2 0 1 0 # ) = XtdComment.objects.all() def test_all_comments_are_public_and_have_not_been_removed(self): @@ -471,10 +502,15 @@ def test_all_comments_are_public_and_have_not_been_removed(self): def test_removing_c1_unpublishes_c3_and_c4(self): cm1 = XtdComment.objects.get(pk=1) + self.assertEqual(cm1.nested_count, 2) # nested_count should be 2. + cm1.is_removed = True cm1.save() + cm1 = XtdComment.objects.get(pk=1) self.assertTrue(cm1.is_public) self.assertTrue(cm1.is_removed) + # Is still public, so the nested_count doesn't change. + self.assertEqual(cm1.nested_count, 2) cm3 = XtdComment.objects.get(pk=3) self.assertFalse(cm3.is_public) @@ -496,31 +532,35 @@ class PublishOrUnpublishNestedComments_2_TestCase(ArticleBaseTestCase): def setUp(self): super(PublishOrUnpublishNestedComments_2_TestCase, self).setUp() - thread_test_step_1(self.article_1, model=MyComment, title="Can't be empty 1") - thread_test_step_2(self.article_1, model=MyComment, title="Can't be empty 2") + thread_test_step_1(self.article_1, model=MyComment, + title="Can't be empty 1") + thread_test_step_2(self.article_1, model=MyComment, + title="Can't be empty 2") # # These two lines create the following comments: # - # ( # content -> cmt.id thread_id parent_id level order - # cm1, # -> 1 1 1 0 1 - # cm3, # -> 3 1 1 1 2 - # cm4, # -> 4 1 1 1 3 - # cm2, # -> 2 2 2 0 1 + # ( # content -> cmt.id thread_id parent_id level order nested + # cm1, # -> 1 1 1 0 1 2 + # cm3, # -> 3 1 1 1 2 0 + # cm4, # -> 4 1 1 1 3 0 + # cm2, # -> 2 2 2 0 1 0 # ) = MyComment.objects.all() - + def test_all_comments_are_public_and_have_not_been_removed(self): for cm in MyComment.objects.all(): self.assertTrue(cm.is_public) self.assertFalse(cm.is_removed) - @patch.multiple('django_comments_xtd.conf.settings', COMMENTS_XTD_MODEL=_model) + @patch.multiple('django_comments_xtd.conf.settings', + COMMENTS_XTD_MODEL=_model) def test_removing_c1_unpublishes_c3_and_c4(self): - # Register the receiver again. It was registered in apps.py, but we have - # patched the COMMENTS_XTD_MODEL, however we won't fake the ready. It's - # easier to just register again the receiver, to test only what depends - # on django-comments-xtd. + # Register the receiver again. It was registered in apps.py, but we + # have patched the COMMENTS_XTD_MODEL, however we won't fake the ready. + # It's easier to just register again the receiver, to test only what + # depends on django-comments-xtd. model_app_label = get_model()._meta.label - pre_save.connect(publish_or_unpublish_on_pre_save, sender=model_app_label) + pre_save.connect(publish_or_unpublish_on_pre_save, + sender=model_app_label) cm1 = MyComment.objects.get(pk=1) cm1.is_removed = True diff --git a/django_comments_xtd/urls.py b/django_comments_xtd/urls.py index a632f675..ae3a7548 100644 --- a/django_comments_xtd/urls.py +++ b/django_comments_xtd/urls.py @@ -1,4 +1,3 @@ -# from django.conf.urls import include, url from django.urls import include, re_path from rest_framework.urlpatterns import format_suffix_patterns @@ -12,7 +11,7 @@ re_path(r'^mute/(?P[^/]+)/$', views.mute, name='comments-xtd-mute'), re_path(r'^reply/(?P[\d]+)/$', views.reply, name='comments-xtd-reply'), - # Remap comments-flag to check allow-flagging is enabled. + # Remap comments-flag to check allow-flagg`_, which is about computing the number of nested comments for every comment at every level down the tree. The fix consists of adding a new field called ``nested_count`` to the **XtdComment** model. Its value represents the number of threaded comments under itself. A new management command, ``initialize_nested_count``, can be used to update the value of the field, the command is idempotent. Two new migrations have been added: migration 0007 adds the new field, and migration 0008 calls the ``initialize_nested_count`` command to populate the ``nested_count`` new field with correct values. + * Fixes issue `#215 `_ about running the tests with Django 3.1 and Python 3.8. + +[2.7.2] - 2020-09-08 +-------------------- + + * Fixes issue `#208 `_, about the JavaScript plugin not displaying the like and dislike buttons and the reply link when django-comments-xtd is setup to allow posting comments only to registered users (``who_can_post: "users"``). + * Fixes issue `#212 `_, about missing i18n JavaScript catalog files for Dutch, German and Russian. + +[2.7.1] - 2020-08-12 +-------------------- + + * Fixes issue `#188 `_, about loading a templatetags module not required for the application. + * Fixes issue `#196 `_. When extending django-comments-xtd's comment model, the receiver function that reviews whether nested comments have to be publish or unpublish is not called. + +[2.7.0] - 2020-08-09 +-------------------- + + * Enhancement, closing issue `#155 `_ (and `#170 `_), on how to post comments via the web API. Up until version 2.6.2 posting comments required the fields timestamp, security_hash and honeypot. As of 2.7.0 there is support allow Django REST Framework authentication classes: ``WriteCommentSerializer`` send the signal ``should_request_be_authorize`` that enables posting comments. Read the documentation about the web API. + * Enhancement, closing issue `#175 `_ on how to customize django-comments-xtd so that user images displayed in comments come from other sources. A new setting ``COMMENTS_XTD_API_GET_USER_AVATAR`` has been added. The docs have been extended with a page that explains the use case in depth. + * Fixes issue `#171 `_, on wrong permission used to decide whether a user is a moderator. The right permission is ``django_comments.can_moderate``. (thanks to Ashwani Gupta, @ashwani99). + * Fixes issue `#136 `_ on missing element in the ``templates/base.html`` file distributed with the **tutorial.tar.gz** bundle. + +[2.6.2] - 2020-07-05 +-------------------- + + * Adds Dutch translation (thanks to Jean-Paul Ladage, @jladage). + * Adds Russian translation (thanks to Михаил Рыбкин, @MikerStudio). + * Fixesissue `#140 `_, which adds the capacity to allow only registered users to post comments. + * Fixesissue `#149 `_, on wrong SQL boolean literal value used when running special command ``populate_xtdcomments`` to load Postgres database with xtdcomments. + * Fixes issue `#154 `_, on using string formatting compatible with Python versions prior to 3.6. + * Fixes issue `#156 `_, on wrong props name ``poll_interval``. JavaScript plugin expects the use of ``polling_interval`` while the ``api/frontend.py`` module referred to it as ``poll_interval``. (thanks to @ashwani99). + * Fixes issue `#159 `_, about using the same id for all the checkboxes in the comment list. When ticking one checkbox in a nested form the checkbox of the main form was ticked. Now each checkbox has a different id, suffixed with the content of the ``reply_to`` field. + +[2.6.1] - 2020-05-13 +-------------------- + + * Fixes issue `#150 `_, about wrong protocol in the URL when fetching avatar images from gravatar. + +[2.6.0] - 2020-05-12 +-------------------- + + * Fixes issue `#145 `_, on inadequate number of SQL queries used by API entry point **comments-xtd-api-list**, available in the URL ``/comments/api///``. The issue also happened when rendering the comments using tags ``get_xtdcomment_tree`` and ``render_xtdcomment_tree``. It has been fixed in both cases too. + * Updates the JSON schema of the output retrieved by the API entry point **comments-xtd-api-list**. Thus the version number change. The flags attribute of each retrieved is now a list of flags instead of a summary for each the flags: "I like it", "I dislike it", "suggest removal". + +[2.5.1] - 2020-04-27 +-------------------- + + * Fixes issue `#138 `_, on unpublishing a single comment with public nested comments. The fix consists of a new ``pre_save`` receiver that will either publish or unpublish nested comments when a comment changes its ``is_public`` attribute. (thanks to @hematinik). + +[2.5.0] - 2020-04-22 +-------------------- + + * Fixes issue `#144 `_ regarding the size of the JavaScript bundle. The new JavaScript plugin does not include React and ReactDOM. The two libraries have to be loaded with an external script. + * Update the dependencies of the JavaScript plugin. + +[2.4.3] - 2020-01-26 +-------------------- + + * Fixes issue on the ContentType that happens when sending post request with empty data. (PR: `#137 `_) (thanks to @dvorberg). + * Adds German translations, (thanks to @dvorberg). + +[2.4.2] - 2019-12-25 +-------------------- + + * Adds Django 3.0 compatibility thanks to Sergey Ivanychev (@ivanychev). + * Adds Norwegian translations thanks to Yngve Høiseth (@yhoiseth). + + +[2.4.1] - 2019-09-30 +-------------------- + + * Allow changing the ``d`` parameter when requesting a gravatar, thanks to @pylixm (PR: `#100 `_). + * Avoid requiring the ``SITE_ID``, thanks to @gassan (PR: `#125 `_). + +[2.4.0] - 2019-02-19 +-------------------- + + New minor release thanks to Mandeep Gill with the following changes: + + * Adds support for non-int based ``object_pk``, for instead when using UUIDs or HashIds as the primary key on a model (closes `#112 `_). + * Refactors the commentbox props generation into a separate function so can be used from the webapi for use with rest-framework/API-only backends that don't make use of server-side templates. + * Adds a **pyproject.yaml** for use with `poetry `_ and new pip environments (PEP 518). + +[2.3.1] - 2019-01-08 +-------------------- + + * Fixes issue `#116 `_. + * Updates package.json JavaScript dependencies: + * babel-cli from 6.24.1 to 6.26.0. + * jquery from 3.2.1 to 3.3.1. + +[2.3.0] - 2018-11-29 +-------------------- + + * Upgrades Twitter-Bootstrap from v3 to v4. + * Fixes issue with tutorial fixtures (bug `#114 `_). + * Upgrade all JavaScript dependencies. Check packages.json for details. The major changes are: + * ReactJS updates from 15.5 to 16.5. + * Babel updates from 6 to 7. + * Webpack from 2.4.1 to 4.21.0. + * Bootstrap from 3.3.7 to 4.1.3. + * Updates webpack.config.js. + * Demo sites and tutorial have been adapted to Twitter Bootstrap v4. + * Fixes issues `#94 `_, `#108 `_, `#111 `_. + +[2.2.1] - 2018-10-06 +-------------------- + + * Resolves deprecation warnings and adopt recommendations in unit tests. + * Fixes demo sites so that they work with Django 1.11, Django 2.0 and Django 2.1. + +[2.2.0] - 2018-08-12 +-------------------- + + * Adds support for Django 2.1. + * Drops support for Django < 1.11 as it depends on django-contrib-comments which dropped support too. + * Fixes issue `#104 `_ (on lack of Django 2.1 support). + +[2.1.0] - 2018-02-13 +-------------------- + + * Fixes issues `#76 `_, `#86 `_ and `#87 `_. + * Request user name and/or email address in case the user is logged in but the user's email attribute is empty and/or the user's ``get_full_name()`` method returns an empty string. + +[2.0.10] - 2018-01-19 +--------------------- + + * Adds Django 2.0 compatibility. + * Fixes issues `#81 `_ and `#83 `_. + * Replaces the use of ``django.test.client`` by ``RequestFactory`` in unittests. + +[2.0.9] - 2017-11-09 +-------------------- + + * Fix issue `#77 `_. Template filter ``xtd_comment_gravatar_url`` must not hard-code http schema in URL (reported by @pamost). + +[2.0.8] - 2017-09-24 +-------------------- + + * App translation to Finnish, thanks to Tero Tikkanen (@terotic). + +[2.0.7] - 2017-09-20 +-------------------- + + * Adds missing migration for a field's label (issue `#71 `_). + * Makes the form label for field ``name`` translatable (issue `#73 `_). + +[2.0.6] - 2017-08-08 +-------------------- + + * Code fixes to enable proper support for the Django Sites Framework. + * Code fixes for the comp demo site. + * Makes demo site dates in initial data files timezone aware. + * Improves documentation on setting up demo sites. + * Style changes in CSS wells. + +[2.0.5] - 2017-07-20 +-------------------- + + * Surpass version number to fix problem with package upload in PyPI. + * No changes applied to this version. + +[2.0.4] - 2017-07-19 +-------------------- + + * Use ``django.core.signing`` with temporary comment passed in URL redirection. + * Fix mistakes in documentation. + +[2.0.3] - 2017-07-10 +-------------------- + + * App translation to French thanks to Brice Gelineau. + * Fixed **MANIFEST.in** file, so that files with translations are distributed. + +[2.0.0] - 2017-06-04 +-------------------- + + * Javascript plugin (based on ReactJS). + * Web API to: + * Create a comment for a given content type and object ID. + * List comments for a given content type and object ID. + * Send feedback flags (like/dislike) on comments. + * Send report flag (removal suggestion) for a comment. + * Template filter ``has_permission`` applicable to a user object and accepting a string specifying the ``app_label.permission`` being checked. It returns ``True`` if the user has the given permission, otherwise returns ``False``. + * Setting ``COMMENTS_XTD_API_USER_REPR`` defines a lambda function to return the user string representation used by the web API in response objects. + * Setting ``COMMENTS_XTD_APP_MODEL_PERMISSIONS`` to explicitly define what commenting features are enabled on per app.model basis. + * Templates ``comments/delete.html`` and ``comments/deleted.html`` matching django-comments-xtd default twitter-bootstrap styling. + * Dependencies on Python packages: djangorestframework. + * Supports i18n for English and Spanish. + * All settings namespaced inside the COMMENTS_XTD setting. + * Management command to migrate comments from django-contrib-comments to django-comments-xtd. + * Enable removal link in ``django_comments_xtd/comment_tree.html`` when the user has the permission ``django_comments.can_moderate``. + * Changed, when the user logged has ``django_comments.can_moderate`` permission, template ``django_comments_xtd/comment_tree.html`` will show the number of removal suggestions a comment has received. + * Changed, when a comment is marked as removed by a moderator (using django-comments' **comments-delete** url) every nested comment below the one removed is unpublished (``is_public`` attribute is turned to ``False``). + * Changed view helper functions, ``perform_like+` and ``perform_dislike`` now returns a boolean indicating whether a flag was created. If ``True`` the flag has been created. If ``False`` the flag has been deleted. These two functions behave as toggle functions. + * Changed templates ``comments/preview.html``, ``comments/flag.html`` and ``comments/flagged.hml``. + * Removed dependency on django-markup. + * Removed template filter ``render_markup_comment``. + * Removed setting ``MARKUP_FALLBACK_FILTER``. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index a270564c..b9a8daf0 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -33,7 +33,7 @@ comments. .. index:: single: preparation pair: tutorial; preparation - + Preparation =========== @@ -84,7 +84,7 @@ Head to http://localhost:8000 and visit the tutorial site. `shortcut` view of `django.contrib.contenttypes` which in turn uses the `get_absolute_url` method. - + .. _configuration: Configuration @@ -244,7 +244,7 @@ before the ``endblock`` tag: {% render_comment_list for object %} {% endif %} - + Below the list of comments we want to display the comment form. There are two template tags available for that purpose, the :ttag:`render_comment_form` and @@ -265,7 +265,7 @@ following code before the ``endblock`` tag: {% endif %} - + .. note:: The ``{% if object.allow_comments %}`` and corresponding ``{% endif %}`` are not necessary in your code. I use it in this tutorial (and in the demo sites) as a way to disable comments whenever the author of a blog post decides so. It has been mentioned `here `_ too. @@ -310,7 +310,7 @@ nested comments. Now we will set up comment moderation. single: Moderation .. _moderation: - + Moderation ========== @@ -425,7 +425,7 @@ file and add: .. code-block:: python COMMENTS_XTD_CONFIRM_EMAIL = False - + django-comments-xtd comes with a **Moderator** class that inherits from ``CommentModerator`` and implements a method ``allow`` that will do the @@ -489,7 +489,7 @@ Now edit ``blog/models.py`` and add the code corresponding to our new from blog.badwords import badwords ... - + class PostCommentModerator(SpamModerator): email_notification = True @@ -528,7 +528,7 @@ Now edit ``blog/models.py`` and add the code corresponding to our new content_object, request) - moderator.register(Post, PostCommentModerator) + moderator.register(Post, PostCommentModerator) Now we can try to send a comment with any of the bad words listed in badwords_. @@ -608,7 +608,7 @@ Edit ``blog/post_detail.html`` to make it look like follows:
{{ object.body|linebreaks }}
- + {% get_comment_count for object as comment_count %}
Back to the post list @@ -625,7 +625,7 @@ Edit ``blog/post_detail.html`` to make it look like follows:
{% endif %} - + {% if comment_count %}
    {% render_xtdcomment_tree for object %} @@ -646,7 +646,7 @@ nest comments inside one level deeper. .. image:: images/reply-link.png - + Different max thread levels --------------------------- @@ -672,6 +672,11 @@ up to level one for blog posts, we would set it up as follows in our 'blog.post': 1, } +The ``nested_count`` field +-------------------------- + +When threaded comments are enabled the field ``nested_count`` of every **XtdComment** instance keeps track of how many nested comments it contains. + Flags ===== @@ -995,7 +1000,7 @@ comments, or to like/dislike them. But it comes at the cost of using: To know more about the client side of the application and the build process read the specific page on the :doc:`javascript`. - + In this section of the tutorial we go through the steps to make use of the JavaScript plugin. @@ -1062,14 +1067,14 @@ Edit ``tutorial/urls.py`` and add the following url: .. code-block:: python from django.views.i18n import JavaScriptCatalog - + urlpatterns = [ ... path(r'jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'), ] In the next section we will use the new URL to load the i18n JavaScript catalog. - + Load the plugin --------------- @@ -1081,9 +1086,9 @@ Now let's edit ``blog/post_detail.html`` and make it look as follows: {% load static %} {% load comments %} {% load comments_xtd %} - + {% block title %}{{ object.title }}{% endblock %} - + {% block content %}

    {{ object.title }}

    @@ -1092,14 +1097,14 @@ Now let's edit ``blog/post_detail.html`` and make it look as follows:
    {{ object.body|linebreaks }}
    - + - +
    {% endblock %} - + {% block extra-js %} @@ -1151,7 +1156,7 @@ plugin, including the following features: #. Immediate like/dislike actions. .. image:: images/update-comment-tree.png - + Final notes ===========