diff --git a/commentary/abstracts.py b/commentary/abstracts.py index 027c505..3f246cc 100644 --- a/commentary/abstracts.py +++ b/commentary/abstracts.py @@ -4,7 +4,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site -from django.db import models +from django.core.validators import int_list_validator +from django.db import models, transaction from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from django.utils.html import strip_tags @@ -15,6 +16,9 @@ from .managers import CommentManager +tree_path_validator = int_list_validator('/', 'Invalid comment tree path') + + class BaseCommentAbstractModel(models.Model): """ An abstract base class that any custom comment models probably should @@ -30,12 +34,44 @@ class BaseCommentAbstractModel(models.Model): ct_field='content_type', fk_field='object_pk' ) + # Comment content + body = models.TextField(_('comment'), db_column='comment') + # Metadata about the comment site = models.ForeignKey(Site, on_delete=models.CASCADE) + is_public = models.BooleanField( + _('is public'), default=True, help_text=_( + 'Uncheck this box to make the comment ' + 'effectively disappear from the site.' + ) + ) + is_removed = models.BooleanField( + _('is removed'), default=False, help_text=_( + 'Check this box if the comment is inappropriate. A "This ' + 'comment has been removed" message will be displayed instead.' + ) + ) + + # Dates + submit_date = models.DateTimeField( + _('date/time submitted'), auto_now_add=True, db_index=True + ) + edit_date = models.DateTimeField( + _('date/time of last edit'), auto_now=True, db_index=True + ) class Meta: abstract = True + @property + def is_edited(self): + """Check whether this comment has been edited.""" + return self.submit_date != self.edit_date + + @cached_property + def _date(self): + return self.submit_date.date() + def get_content_object_url(self): """ Get a URL suitable for redirecting to the content object. @@ -45,54 +81,66 @@ def get_content_object_url(self): args=(self.content_type_id, self.object_pk) ) + def get_absolute_url(self, anchor_pattern='#c%(id)s'): + return self.get_content_object_url() + (anchor_pattern % self.__dict__) -@python_2_unicode_compatible -class CommentAbstractModel(BaseCommentAbstractModel): - """ - A user comment about some object. - """ - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - related_name='%(class)s_comments', - verbose_name=_('user'), blank=True, + +class AbstractTreeModel(models.Model): + """An abstract model class representing a tree structure.""" + parent = models.ForeignKey( + 'self', related_name='replies', blank=True, + null=True, on_delete=models.CASCADE + ) + path = models.TextField( + 'tree path', editable=False, db_index=True, + validators=(tree_path_validator,) + ) + leaf = models.ForeignKey( + 'self', verbose_name='last child', blank=True, null=True, on_delete=models.SET_NULL ) - body = models.TextField(_('comment'), db_column='comment') + @property + def _nodes(self): + """Get the nodes of the path.""" + return self.path.split('/') - submit_date = models.DateTimeField( - _('date/time submitted'), auto_now_add=True, db_index=True - ) - edit_date = models.DateTimeField( - _('date/time of last edit'), auto_now=True, db_index=True - ) + @property + def depth(self): + """Get the depth of the tree.""" + return len(self._nodes) - is_public = models.BooleanField( - _('is public'), default=True, help_text=_( - 'Uncheck this box to make the comment ' - 'effectively disappear from the site.' - ) - ) - is_removed = models.BooleanField( - _('is removed'), default=False, help_text=_( - 'Check this box if the comment is inappropriate. A "This ' - 'comment has been removed" message will be displayed instead.' - ) - ) + @property + def root(self): + """Get the id of the root node.""" + return int(self._nodes[0]) - parent = models.ForeignKey( - 'self', related_name='replies', blank=True, - null=True, on_delete=models.CASCADE - ) + @property + def ancestors(self): + """Get all nodes in the path excluding the last one.""" + return AbstractTreeModel.objects.filter(pk__in=self._nodes[:-1]) - # TODO: add upvotes & downvotes + class Meta: + abstract = True + ordering = ('path',) + + +@python_2_unicode_compatible +class CommentAbstractModel(AbstractTreeModel, BaseCommentAbstractModel): + """A user's comment about some object.""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='%(class)s_comments', + verbose_name=_('user'), blank=True, + null=True, on_delete=models.SET_NULL + ) # Manager objects = CommentManager() class Meta: abstract = True - ordering = ('submit_date',) + ordering = ('path', 'submit_date') permissions = ( ('can_moderate', 'Can moderate comments'), ) @@ -102,23 +150,11 @@ class Meta: def __str__(self): return '%s: %s...' % (self.user_display, strip_tags(self.body)[:50]) - @property - def is_edited(self): - """Check whether this comment has been edited.""" - return self.submit_date != self.edit_date - @property def user_display(self): """Display the full name/username of the commenter.""" return get_user_display(self.user) - @cached_property - def _date(self): - return self.submit_date.date() - - def get_absolute_url(self, anchor_pattern='#c%(id)s'): - return self.get_content_object_url() + (anchor_pattern % self.__dict__) - def strip_body(self): return strip_tags(self.body) if COMMENTS_ALLOW_HTML else self.body @@ -136,3 +172,27 @@ def get_as_text(self): 'domain': self.site.domain, 'url': self.get_absolute_url() } + + def is_editable_by(self, user): + """Check if a comment can be edited or removed by a user.""" + return user == self.user + + @transaction.atomic + def save(self, *args, **kwargs): + super(CommentAbstractModel, self).save(*args, **kwargs) + tree_path = str(self.id) + if self.parent: + tree_path = '%s/%s' % (self.parent.path, tree_path) + self.parent.leaf = self + CommentManager.filter(pk=self.parent_id).update(leaf=self.id) + self.path = tree_path + CommentManager.filter(id=self.id).update(path=self.path) + + def delete(self, *args, **kwargs): + if self.parent_id: + qs = CommentManager.filter(id=self.parent_id) + qs.update(leaf=models.Subquery( + qs.exclude(id=self.id).only('id') + .order_by('-submit_date').values('id')[:1] + )) + super(CommentAbstractModel, self).delete(*args, **kwargs) diff --git a/commentary/admin.py b/commentary/admin.py index 62b8b87..9029274 100644 --- a/commentary/admin.py +++ b/commentary/admin.py @@ -14,7 +14,7 @@ class CommentsAdmin(admin.ModelAdmin): list_display = ( - 'user', 'content_type', 'object_pk', + 'user', 'content_type', 'object_pk', 'parent', 'submit_date', 'is_public', 'is_removed' ) list_filter = ( @@ -23,7 +23,7 @@ class CommentsAdmin(admin.ModelAdmin): ) date_hierarchy = 'submit_date' ordering = ('-submit_date',) - raw_id_fields = ('user',) + raw_id_fields = ('user', 'parent') search_fields = ('body', USERNAME_FIELD) actions = ('flag_comments', 'approve_comments', 'remove_comments') diff --git a/commentary/migrations/0001_initial.py b/commentary/migrations/0001_initial.py index 6cbaae0..ef98428 100644 --- a/commentary/migrations/0001_initial.py +++ b/commentary/migrations/0001_initial.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): @@ -17,32 +17,59 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Comment', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('id', models.AutoField( + verbose_name='ID', serialize=False, + auto_created=True, primary_key=True + )), ('object_pk', models.TextField(verbose_name='object ID')), - ('user_name', models.CharField(max_length=50, verbose_name="user's name", blank=True)), - ('user_email', models.EmailField(max_length=75, verbose_name="user's email address", blank=True)), - ('user_url', models.URLField(verbose_name="user's URL", blank=True)), - ('comment', models.TextField(max_length=3000, verbose_name='comment')), - ('submit_date', models.DateTimeField(default=None, verbose_name='date/time submitted')), + ('user_name', models.CharField( + max_length=50, blank=True, verbose_name="user's name" + )), + ('user_email', models.EmailField( + max_length=75, blank=True, + verbose_name="user's email address" + )), + ('user_url', models.URLField( + verbose_name="user's URL", blank=True + )), + ('comment', models.TextField( + max_length=3000, verbose_name='comment' + )), + ('submit_date', models.DateTimeField( + default=None, verbose_name='date/time submitted' + )), ('ip_address', models.GenericIPAddressField( - unpack_ipv4=True, null=True, verbose_name='IP address', blank=True)), - ('is_public', models.BooleanField(default=True, - help_text='Uncheck this box to make the comment effectively disappear from the site.', - verbose_name='is public')), - ('is_removed', models.BooleanField(default=False, - help_text='Check this box if the comment is inappropriate. A "This comment has been removed"' - ' message will be displayed instead.', - verbose_name='is removed')), - ('content_type', models.ForeignKey(related_name='content_type_set_for_comment', + unpack_ipv4=True, null=True, + verbose_name='IP address', blank=True + )), + ('is_public', models.BooleanField( + default=True, verbose_name='is public', + help_text='Uncheck this box to make the comment' + ' effectively disappear from the site.', + )), + ('is_removed', models.BooleanField( + default=False, verbose_name='is removed', + help_text='Check this box if the comment is inappropriate.' + ' A "This comment has been removed" message' + ' will be displayed instead.' + )), + ('content_type', models.ForeignKey( + related_name='content_type_set_for_comment', verbose_name='content type', to='contenttypes.ContentType', - on_delete=models.CASCADE)), - ('site', models.ForeignKey(to='sites.Site', on_delete=models.CASCADE)), - ('user', models.ForeignKey(related_name='comment_comments', verbose_name='user', - blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)), + on_delete=models.CASCADE + )), + ('site', models.ForeignKey( + to='sites.Site', on_delete=models.CASCADE + )), + ('user', models.ForeignKey( + related_name='comment_comments', verbose_name='user', + blank=True, to=settings.AUTH_USER_MODEL, + null=True, on_delete=models.SET_NULL + )), ], options={ 'ordering': ('submit_date',), - 'db_table': 'commentary', + 'db_table': 'django_comments', 'verbose_name': 'comment', 'verbose_name_plural': 'comments', 'permissions': [('can_moderate', 'Can moderate comments')], @@ -52,13 +79,24 @@ class Migration(migrations.Migration): migrations.CreateModel( name='CommentFlag', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('flag', models.CharField(max_length=30, verbose_name='flag', db_index=True)), - ('flag_date', models.DateTimeField(default=None, verbose_name='date')), - ('comment', models.ForeignKey(related_name='flags', verbose_name='comment', - to='commentary.Comment', on_delete=models.CASCADE)), - ('user', models.ForeignKey(related_name='comment_flags', verbose_name='user', - to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ('id', models.AutoField( + verbose_name='ID', serialize=False, + auto_created=True, primary_key=True + )), + ('flag', models.CharField( + max_length=30, verbose_name='flag', db_index=True + )), + ('flag_date', models.DateTimeField( + default=None, verbose_name='date' + )), + ('comment', models.ForeignKey( + related_name='flags', verbose_name='comment', + to='commentary.Comment', on_delete=models.CASCADE + )), + ('user', models.ForeignKey( + related_name='comment_flags', verbose_name='user', + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + )), ], options={ 'db_table': 'django_comment_flags', @@ -69,6 +107,6 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='commentflag', - unique_together=set([('user', 'comment', 'flag')]), + unique_together={'user', 'comment', 'flag'}, ), ] diff --git a/commentary/migrations/0002_update_user_email_field_length.py b/commentary/migrations/0002_update_user_email_field_length.py index d456879..56c5db4 100644 --- a/commentary/migrations/0002_update_user_email_field_length.py +++ b/commentary/migrations/0002_update_user_email_field_length.py @@ -15,7 +15,8 @@ class Migration(migrations.Migration): model_name='comment', name='user_email', field=models.EmailField( - max_length=254, verbose_name="user's email address", - blank=True), + max_length=254, blank=True, + verbose_name="user's email address", + ), ), ] diff --git a/commentary/migrations/0003_add_submit_date_index.py b/commentary/migrations/0003_add_submit_date_index.py index dadc29b..4919586 100644 --- a/commentary/migrations/0003_add_submit_date_index.py +++ b/commentary/migrations/0003_add_submit_date_index.py @@ -14,7 +14,10 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='comment', name='submit_date', - field=models.DateTimeField(default=None, verbose_name='date/time submitted', db_index=True), + field=models.DateTimeField( + default=None, db_index=True, + verbose_name='date/time submitted' + ), preserve_default=True, ), ] diff --git a/commentary/migrations/0004_commentary.py b/commentary/migrations/0004_commentary.py index a281733..63df94d 100644 --- a/commentary/migrations/0004_commentary.py +++ b/commentary/migrations/0004_commentary.py @@ -1,21 +1,37 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import re + +from django.core.validators import RegexValidator from django.db import migrations, models +from django.db.models.functions import Cast -class Migration(migrations.Migration): +def set_comment_paths(apps, schema_editor): + Comment = apps.get_model('commentary', 'Comment') + pk = Cast('pk', models.TextField()) + Comment.objects.all().update(path=pk) + + +comment_path_validator = RegexValidator( + re.compile(r'^\d+(?:/\d+)*\Z'), code='invalid', + message='Invalid comment tree path' +) + +class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), ('commentary', '0003_add_submit_date_index'), ] operations = [ + migrations.AlterModelTable(name='comment', table=None), + migrations.AlterModelTable(name='commentflag', table=None), migrations.AlterModelOptions( - name='comment', - options={ - 'ordering': ('submit_date',), + name='comment', options={ + 'ordering': ('path', 'submit_date'), 'permissions': ( ('can_moderate', 'Can moderate comments'), ), @@ -54,6 +70,28 @@ class Migration(migrations.Migration): related_name='replies', to='commentary.Comment' ), ), + migrations.AddField( + model_name='comment', name='leaf', + field=models.ForeignKey( + blank=True, null=True, on_delete=models.deletion.SET_NULL, + to='commentary.Comment', verbose_name='last child' + ), + ), + migrations.AddField( + model_name='comment', name='path', + field=models.TextField( + db_index=True, blank=True, default='', editable=False, + validators=(comment_path_validator,), verbose_name='tree path', + ), + ), + migrations.RunPython(set_comment_paths, migrations.RunPython.noop), + migrations.AlterField( + model_name='comment', name='path', + field=models.TextField( + db_index=True, blank=False, editable=False, + validators=(comment_path_validator,), verbose_name='tree path', + ), + ), migrations.RemoveField(model_name='comment', name='ip_address'), migrations.RemoveField(model_name='comment', name='user_email'), migrations.RemoveField(model_name='comment', name='user_name'), @@ -62,8 +100,7 @@ class Migration(migrations.Migration): name='comment', index_together={('content_type', 'object_pk')}, ), migrations.AlterField( - model_name='commentflag', - name='flag_date', + model_name='commentflag', name='flag_date', field=models.DateTimeField(auto_now=True, verbose_name='date'), ), ] diff --git a/commentary/models.py b/commentary/models.py index c6739b7..c63f35e 100644 --- a/commentary/models.py +++ b/commentary/models.py @@ -4,19 +4,11 @@ from django.utils.translation import ugettext_lazy as _ from . import get_user_display -from .abstracts import BaseCommentAbstractModel, CommentAbstractModel +from .abstracts import CommentAbstractModel class Comment(CommentAbstractModel): - def is_removable_by(self, user): - return user == self.user or \ - user.perms.has_perm('commentary.can_moderate') - - def is_editable_by(self, user): - return user == self.user - class Meta(CommentAbstractModel.Meta): - db_table = 'commentary' index_together = ( ('content_type', 'object_pk') ) @@ -56,7 +48,6 @@ class CommentFlag(models.Model): MODERATOR_APPROVAL = 'moderator approval' class Meta: - db_table = 'django_comment_flags' unique_together = ( ('user', 'comment', 'flag'), ) diff --git a/commentary/views/comments.py b/commentary/views/comments.py index e770b10..526b1ac 100644 --- a/commentary/views/comments.py +++ b/commentary/views/comments.py @@ -63,7 +63,7 @@ def post_comment(request, next=None, using=None): except (ValueError, ValidationError) as e: return CommentPostBadRequest( 'Attempting to get content-type %r ' - 'and object PK %r exists raised %s' % ( + 'and object PK %r raised %s' % ( escape(ctype), escape(object_pk), e.__class__.__name__ ) ) diff --git a/setup.py b/setup.py index 3ccb681..a1e2220 100644 --- a/setup.py +++ b/setup.py @@ -44,5 +44,5 @@ packages=find_packages(exclude=['tests', 'tests.*']), include_package_data=True, test_suite='tests.runtests.main', - install_requires=['Django>=1.11'] + install_requires=['Django>=1.11,<3.0'] )