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:
parent
b48c6dab58
commit
ab3c8d7d3d
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
@ -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
wagtail/core/tests/test_comments.py
Normal file
76
wagtail/core/tests/test_comments.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user