ソースを参照

[Comments] Initial models (#6405)

Add initial comment models, tests, and logic for updating comment foreignkeys when revisions are deleted
jacobtoppm 4 年 前
コミット
ab3c8d7d3d

+ 62 - 0
wagtail/core/migrations/0062_comment_commentposition_commentreply.py

@@ -0,0 +1,62 @@
+# Generated by Django 3.0.3 on 2020-10-21 13:29
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('wagtailcore', '0061_change_promote_tab_helpt_text_and_verbose_names'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Comment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('text', models.TextField()),
+                ('contentpath', models.TextField()),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField(auto_now=True)),
+                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='wagtailcore.Page')),
+                ('revision_created', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_comments', to='wagtailcore.PageRevision')),
+                ('revision_resolved', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_comments', to='wagtailcore.PageRevision')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'comment',
+                'verbose_name_plural': 'comments',
+            },
+        ),
+        migrations.CreateModel(
+            name='CommentReply',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('text', models.TextField()),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField(auto_now=True)),
+                ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='wagtailcore.Comment')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_replies', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'verbose_name': 'comment reply',
+                'verbose_name_plural': 'comment replies',
+            },
+        ),
+        migrations.CreateModel(
+            name='CommentPosition',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('position', models.TextField()),
+                ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to='wagtailcore.Comment')),
+                ('revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_position', to='wagtailcore.PageRevision')),
+            ],
+            options={
+                'verbose_name': 'comment position',
+                'verbose_name_plural': 'comment positions',
+            },
+        ),
+    ]

+ 97 - 0
wagtail/core/models.py

@@ -2970,6 +2970,23 @@ class PageRevision(models.Model):
         latest_revision = PageRevision.objects.filter(page_id=self.page_id).order_by('-created_at', '-id').first()
         return (latest_revision == self)
 
+    def delete(self):
+        # Update Comment revision_resolved and revision_created fields for comments that reference the current revision, if applicable.
+
+        try:
+            next_revision = self.get_next()
+        except PageRevision.DoesNotExist:
+            next_revision = None
+
+        # otherwise, update the revision_resolved to the next revision (or unresolve it if None)
+        self.resolved_comments.all().update(revision_resolved=next_revision)
+
+        if next_revision:
+            # move comments created on this revision (and not resolved on the next) to the next revision, as they may well still apply if they're unresolved
+            self.created_comments.all().exclude(revision_resolved=next_revision).update(revision_created=next_revision)
+
+        return super().delete()
+
     def publish(self, user=None, changed=True, log_action=True, previous_revision=None):
         """
         Publishes or schedules revision for publishing.
@@ -4847,3 +4864,83 @@ class PageLogEntry(BaseLogEntry):
     @cached_property
     def object_id(self):
         return self.page_id
+
+
+class Comment(models.Model):
+    """
+    A comment on a field, or a field within a streamfield block. This model stores the comment data that applies to all page revisions.
+    Any data which applies only for a single revision, or may change between revisions (such as position within a field) is stored on
+    CommentPosition.
+    """
+    page = models.ForeignKey(Page, on_delete=models.CASCADE, related_name='comments')
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments')
+    text = models.TextField()
+
+    contentpath = models.TextField()
+    # This stores the field or field within a streamfield block that the comment is applied on, in the form: 'field', or 'field.block_id.field'
+    # This must be unchanging across all revisions, so we will not support (current-format) ListBlock or the contents of InlinePanels initially.
+    # When these are included, it should probably be in the form of a "changable contentpath" extension within CommentPosition
+
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    revision_created = models.ForeignKey(PageRevision, on_delete=models.CASCADE, related_name='created_comments')
+    # Comments are only shown on revisions after revision_created
+    revision_resolved = models.ForeignKey(PageRevision, on_delete=models.SET_NULL, related_name='resolved_comments', null=True, blank=True)
+    # A null value here indicates the comment is unresolved. Resolved comments can only be seen on revisions prior to their resolved revision.
+    # In most cases, revisions will be purged oldest-first, so deleting the comment when the revision is deleted is the correct behaviour as the resolved
+    # comment is now inaccessible. However, in cases where the deleted revision_resolved is not the oldest revision, the revision_resolved needs to be
+    # changed instead. This is done in PageRevision.delete()
+
+    class Meta:
+        verbose_name = _('comment')
+        verbose_name_plural = _('comments')
+
+    def __str__(self):
+        return "Comment on Page '{0}', left by {1}: '{2}'".format(self.page, self.user, self.text)
+
+    def clean(self):
+        if self.revision_resolved and not (self.revision_created.created_at < self.revision_resolved.created_at):
+            raise ValidationError(
+                _("A comment must be resolved on a revision newer than the revision it was created on. If the two revisions are the same, it should be deleted instead")
+            )
+        return super().clean()
+
+    def save(self, **kwargs):
+        self.full_clean()
+        super().save(**kwargs)
+
+
+class CommentPosition(models.Model):
+    """
+    The revision-specific position data for a Comment. If the Comment is field level, it may not have a CommentPosition at all.
+    """
+
+    revision = models.ForeignKey(PageRevision, on_delete=models.CASCADE, related_name='comment_position')
+    comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name='positions')
+
+    position = models.TextField()
+    # The position of the comment within a field. The meaning and content of this is determined by the field itself: for example,
+    # for a RichTextField this could be a paragraph id and numerical offsets to indicate a text range
+
+    class Meta:
+        verbose_name = _('comment position')
+        verbose_name_plural = _('comment positions')
+
+    def __str__(self):
+        return "CommentPosition for Comment '{0}' on PageRevision '{1}'".format(self.comment.text, self.revision)
+
+
+class CommentReply(models.Model):
+    comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name='replies')
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comment_replies')
+    text = models.TextField()
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    class Meta:
+        verbose_name = _('comment reply')
+        verbose_name_plural = _('comment replies')
+
+    def __str__(self):
+        return "CommentReply left by '{0}': '{1}'".format(self.user, self.text)

+ 76 - 0
wagtail/core/tests/test_comments.py

@@ -0,0 +1,76 @@
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from wagtail.core.models import Comment, Page
+
+
+class CommentTestingUtils:
+    def setUp(self):
+        self.page = Page.objects.get(title="Welcome to the Wagtail test site!")
+        self.revision_1 = self.page.save_revision()
+        self.revision_2 = self.page.save_revision()
+
+    def create_comment(self, revision_created, revision_resolved=None):
+        return Comment.objects.create(
+            page=self.page,
+            user=get_user_model().objects.first(),
+            text='test',
+            contentpath='title',
+            revision_created=revision_created,
+            revision_resolved=revision_resolved
+        )
+
+
+class TestCommentModels(CommentTestingUtils, TestCase):
+    fixtures = ['test.json']
+
+    def test_revision_resolved_not_after_revision_created_raises_error(self):
+        with self.assertRaises(ValidationError):
+            self.create_comment(self.revision_2, revision_resolved=self.revision_1)
+
+        with self.assertRaises(ValidationError):
+            self.create_comment(self.revision_1, revision_resolved=self.revision_1)
+
+
+class TestRevisionDeletion(CommentTestingUtils, TestCase):
+    fixtures = ['test.json']
+
+    def setUp(self):
+        super().setUp()
+        self.revision_3 = self.page.save_revision()
+        self.old_comment = self.create_comment(self.revision_1)
+        self.old_resolved_comment = self.create_comment(self.revision_1, revision_resolved=self.revision_2)
+        self.newly_resolved_comment = self.create_comment(self.revision_1, revision_resolved=self.revision_3)
+        self.new_comment = self.create_comment(self.revision_3)
+
+    def test_deleting_old_revision_moves_comment_revision_created_forwards(self):
+        # test that when a revision is deleted, a comment linked to it via revision_created has its revision_created moved
+        # to the next revision
+        self.revision_1.delete()
+        self.old_comment.refresh_from_db()
+        self.assertEqual(self.old_comment.revision_created, self.revision_2)
+
+    def test_deleting_most_recent_revision_deletes_created_comments(self):
+        # test that when the most recent revision is deleted, any comments created on it are also deleted
+        self.revision_3.delete()
+        with self.assertRaises(Comment.DoesNotExist):
+            self.new_comment.refresh_from_db()
+
+    def test_revision_deletion_when_revision_resolved_is_next_revision(self):
+        # test that when a comment's revision_created and revision_resolved are neighbouring revisions, deleting the revision_created
+        # deletes the comment, as a comment's revision_resolved must be after its revision_created
+        self.revision_1.delete()
+        with self.assertRaises(Comment.DoesNotExist):
+            self.old_resolved_comment.refresh_from_db()
+
+    def test_deleting_resolving_revision(self):
+        # test that when a revision linked to a comment via its revision_resolved is deleted, the comment's revision_resolved is
+        # moved to the next revision, or unresolved if there is none
+        self.revision_2.delete()
+        self.old_resolved_comment.refresh_from_db()
+        self.assertEqual(self.old_resolved_comment.revision_resolved, self.revision_3)
+
+        self.revision_3.delete()
+        self.newly_resolved_comment.refresh_from_db()
+        self.assertIsNone(self.newly_resolved_comment.revision_resolved)