0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-12-01 11:41:20 +01:00

[Comments] Initial models (#6405)

Add initial comment models, tests, and logic for updating comment foreignkeys when revisions are deleted
This commit is contained in:
jacobtoppm 2020-10-19 14:53:26 +01:00 committed by Matt Westcott
parent b48c6dab58
commit ab3c8d7d3d
3 changed files with 235 additions and 0 deletions

View File

@ -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',
},
),
]

View File

@ -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)

View File

@ -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)