Skip to content
This repository has been archived by the owner on Jan 7, 2021. It is now read-only.

Commit

Permalink
Implement threaded comment tree model [ci skip]
Browse files Browse the repository at this point in the history
  • Loading branch information
ObserverOfTime committed Oct 28, 2019
1 parent 2998675 commit 3995599
Show file tree
Hide file tree
Showing 9 changed files with 229 additions and 99 deletions.
154 changes: 107 additions & 47 deletions commentary/abstracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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'),
)
Expand All @@ -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

Expand All @@ -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)
4 changes: 2 additions & 2 deletions commentary/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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')

Expand Down
96 changes: 67 additions & 29 deletions commentary/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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')],
Expand All @@ -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',
Expand All @@ -69,6 +107,6 @@ class Migration(migrations.Migration):
),
migrations.AlterUniqueTogether(
name='commentflag',
unique_together=set([('user', 'comment', 'flag')]),
unique_together={'user', 'comment', 'flag'},
),
]
5 changes: 3 additions & 2 deletions commentary/migrations/0002_update_user_email_field_length.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
),
]
5 changes: 4 additions & 1 deletion commentary/migrations/0003_add_submit_date_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]
Loading

0 comments on commit 3995599

Please sign in to comment.