Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Fixed #9282: added a generic comment moderation toolkit. See the docu…

…mentation for details.

This began life as (part of) James Bennett's comment-utils app, and was adapted to be part of Django by Thejaswi Puthraya and Jannis Leidel. Thanks, all!

git-svn-id: http://code.djangoproject.com/svn/django/trunk@10122 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit f0560dfdb2adaa44fc739941a2a784c558ae6427 1 parent 44f3080
@jacobian jacobian authored
View
442 django/contrib/comments/moderation.py
@@ -0,0 +1,442 @@
+"""
+A generic comment-moderation system which allows configuration of
+moderation options on a per-model basis.
+
+Originally part of django-comment-utils, by James Bennett.
+
+To use, do two things:
+
+1. Create or import a subclass of ``CommentModerator`` defining the
+ options you want.
+
+2. Import ``moderator`` from this module and register one or more
+ models, passing the models and the ``CommentModerator`` options
+ class you want to use.
+
+
+Example
+-------
+
+First, we define a simple model class which might represent entries in
+a weblog::
+
+ from django.db import models
+
+ class Entry(models.Model):
+ title = models.CharField(maxlength=250)
+ body = models.TextField()
+ pub_date = models.DateField()
+ enable_comments = models.BooleanField()
+
+Then we create a ``CommentModerator`` subclass specifying some
+moderation options::
+
+ from django.contrib.comments.moderation import CommentModerator, moderator
+
+ class EntryModerator(CommentModerator):
+ email_notification = True
+ enable_field = 'enable_comments'
+
+And finally register it for moderation::
+
+ moderator.register(Entry, EntryModerator)
+
+This sample class would apply several moderation steps to each new
+comment submitted on an Entry:
+
+* If the entry's ``enable_comments`` field is set to ``False``, the
+ comment will be rejected (immediately deleted).
+
+* If the comment is successfully posted, an email notification of the
+ comment will be sent to site staff.
+
+For a full list of built-in moderation options and other
+configurability, see the documentation for the ``CommentModerator``
+class.
+
+Several example subclasses of ``CommentModerator`` are provided in
+`django-comment-utils`_, both to provide common moderation options and to
+demonstrate some of the ways subclasses can customize moderation
+behavior.
+
+.. _`django-comment-utils`: http://code.google.com/p/django-comment-utils/
+"""
+
+import datetime
+
+from django.conf import settings
+from django.core.mail import send_mail
+from django.db.models import signals
+from django.db.models.base import ModelBase
+from django.template import Context, loader
+from django.contrib import comments
+from django.contrib.sites.models import Site
+
+class AlreadyModerated(Exception):
+ """
+ Raised when a model which is already registered for moderation is
+ attempting to be registered again.
+
+ """
+ pass
+
+class NotModerated(Exception):
+ """
+ Raised when a model which is not registered for moderation is
+ attempting to be unregistered.
+
+ """
+ pass
+
+class CommentModerator(object):
+ """
+ Encapsulates comment-moderation options for a given model.
+
+ This class is not designed to be used directly, since it doesn't
+ enable any of the available moderation options. Instead, subclass
+ it and override attributes to enable different options::
+
+ ``auto_close_field``
+ If this is set to the name of a ``DateField`` or
+ ``DateTimeField`` on the model for which comments are
+ being moderated, new comments for objects of that model
+ will be disallowed (immediately deleted) when a certain
+ number of days have passed after the date specified in
+ that field. Must be used in conjunction with
+ ``close_after``, which specifies the number of days past
+ which comments should be disallowed. Default value is
+ ``None``.
+
+ ``auto_moderate_field``
+ Like ``auto_close_field``, but instead of outright
+ deleting new comments when the requisite number of days
+ have elapsed, it will simply set the ``is_public`` field
+ of new comments to ``False`` before saving them. Must be
+ used in conjunction with ``moderate_after``, which
+ specifies the number of days past which comments should be
+ moderated. Default value is ``None``.
+
+ ``close_after``
+ If ``auto_close_field`` is used, this must specify the
+ number of days past the value of the field specified by
+ ``auto_close_field`` after which new comments for an
+ object should be disallowed. Default value is ``None``.
+
+ ``email_notification``
+ If ``True``, any new comment on an object of this model
+ which survives moderation will generate an email to site
+ staff. Default value is ``False``.
+
+ ``enable_field``
+ If this is set to the name of a ``BooleanField`` on the
+ model for which comments are being moderated, new comments
+ on objects of that model will be disallowed (immediately
+ deleted) whenever the value of that field is ``False`` on
+ the object the comment would be attached to. Default value
+ is ``None``.
+
+ ``moderate_after``
+ If ``auto_moderate_field`` is used, this must specify the number
+ of days past the value of the field specified by
+ ``auto_moderate_field`` after which new comments for an
+ object should be marked non-public. Default value is
+ ``None``.
+
+ Most common moderation needs can be covered by changing these
+ attributes, but further customization can be obtained by
+ subclassing and overriding the following methods. Each method will
+ be called with two arguments: ``comment``, which is the comment
+ being submitted, and ``content_object``, which is the object the
+ comment will be attached to::
+
+ ``allow``
+ Should return ``True`` if the comment should be allowed to
+ post on the content object, and ``False`` otherwise (in
+ which case the comment will be immediately deleted).
+
+ ``email``
+ If email notification of the new comment should be sent to
+ site staff or moderators, this method is responsible for
+ sending the email.
+
+ ``moderate``
+ Should return ``True`` if the comment should be moderated
+ (in which case its ``is_public`` field will be set to
+ ``False`` before saving), and ``False`` otherwise (in
+ which case the ``is_public`` field will not be changed).
+
+ Subclasses which want to introspect the model for which comments
+ are being moderated can do so through the attribute ``_model``,
+ which will be the model class.
+
+ """
+ auto_close_field = None
+ auto_moderate_field = None
+ close_after = None
+ email_notification = False
+ enable_field = None
+ moderate_after = None
+
+ def __init__(self, model):
+ self._model = model
+
+ def _get_delta(self, now, then):
+ """
+ Internal helper which will return a ``datetime.timedelta``
+ representing the time between ``now`` and ``then``. Assumes
+ ``now`` is a ``datetime.date`` or ``datetime.datetime`` later
+ than ``then``.
+
+ If ``now`` and ``then`` are not of the same type due to one of
+ them being a ``datetime.date`` and the other being a
+ ``datetime.datetime``, both will be coerced to
+ ``datetime.date`` before calculating the delta.
+
+ """
+ if now.__class__ is not then.__class__:
+ now = datetime.date(now.year, now.month, now.day)
+ then = datetime.date(then.year, then.month, then.day)
+ if now < then:
+ raise ValueError("Cannot determine moderation rules because date field is set to a value in the future")
+ return now - then
+
+ def allow(self, comment, content_object):
+ """
+ Determine whether a given comment is allowed to be posted on
+ a given object.
+
+ Return ``True`` if the comment should be allowed, ``False
+ otherwise.
+
+ """
+ if self.enable_field:
+ if not getattr(content_object, self.enable_field):
+ return False
+ if self.auto_close_field and self.close_after:
+ if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_close_field)).days >= self.close_after:
+ return False
+ return True
+
+ def moderate(self, comment, content_object):
+ """
+ Determine whether a given comment on a given object should be
+ allowed to show up immediately, or should be marked non-public
+ and await approval.
+
+ Return ``True`` if the comment should be moderated (marked
+ non-public), ``False`` otherwise.
+
+ """
+ if self.auto_moderate_field and self.moderate_after:
+ if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_moderate_field)).days >= self.moderate_after:
+ return True
+ return False
+
+ def comments_open(self, obj):
+ """
+ Return ``True`` if new comments are being accepted for
+ ``obj``, ``False`` otherwise.
+
+ The algorithm for determining this is as follows:
+
+ 1. If ``enable_field`` is set and the relevant field on
+ ``obj`` contains a false value, comments are not open.
+
+ 2. If ``close_after`` is set and the relevant date field on
+ ``obj`` is far enough in the past, comments are not open.
+
+ 3. If neither of the above checks determined that comments are
+ not open, comments are open.
+
+ """
+ if self.enable_field:
+ if not getattr(obj, self.enable_field):
+ return False
+ if self.auto_close_field and self.close_after:
+ if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_close_field)).days >= self.close_after:
+ return False
+ return True
+
+ def comments_moderated(self, obj):
+ """
+ Return ``True`` if new comments for ``obj`` are being
+ automatically sent to moderation, ``False`` otherwise.
+
+ The algorithm for determining this is as follows:
+
+ 1. If ``moderate_field`` is set and the relevant field on
+ ``obj`` contains a true value, comments are moderated.
+
+ 2. If ``moderate_after`` is set and the relevant date field on
+ ``obj`` is far enough in the past, comments are moderated.
+
+ 3. If neither of the above checks decided that comments are
+ moderated, comments are not moderated.
+
+ """
+ if self.moderate_field:
+ if getattr(obj, self.moderate_field):
+ return True
+ if self.auto_moderate_field and self.moderate_after:
+ if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_moderate_field)).days >= self.moderate_after:
+ return True
+ return False
+
+ def email(self, comment, content_object):
+ """
+ Send email notification of a new comment to site staff when email
+ notifications have been requested.
+
+ """
+ if not self.email_notification:
+ return
+ recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS]
+ t = loader.get_template('comments/comment_notification_email.txt')
+ c = Context({ 'comment': comment,
+ 'content_object': content_object })
+ subject = '[%s] New comment posted on "%s"' % (Site.objects.get_current().name,
+ content_object)
+ message = t.render(c)
+ send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True)
+
+class Moderator(object):
+ """
+ Handles moderation of a set of models.
+
+ An instance of this class will maintain a list of one or more
+ models registered for comment moderation, and their associated
+ moderation classes, and apply moderation to all incoming comments.
+
+ To register a model, obtain an instance of ``CommentModerator``
+ (this module exports one as ``moderator``), and call its
+ ``register`` method, passing the model class and a moderation
+ class (which should be a subclass of ``CommentModerator``). Note
+ that both of these should be the actual classes, not instances of
+ the classes.
+
+ To cease moderation for a model, call the ``unregister`` method,
+ passing the model class.
+
+ For convenience, both ``register`` and ``unregister`` can also
+ accept a list of model classes in place of a single model; this
+ allows easier registration of multiple models with the same
+ ``CommentModerator`` class.
+
+ The actual moderation is applied in two phases: one prior to
+ saving a new comment, and the other immediately after saving. The
+ pre-save moderation may mark a comment as non-public or mark it to
+ be removed; the post-save moderation may delete a comment which
+ was disallowed (there is currently no way to prevent the comment
+ being saved once before removal) and, if the comment is still
+ around, will send any notification emails the comment generated.
+
+ """
+ def __init__(self):
+ self._registry = {}
+ self.connect()
+
+ def connect(self):
+ """
+ Hook up the moderation methods to pre- and post-save signals
+ from the comment models.
+
+ """
+ signals.pre_save.connect(self.pre_save_moderation, sender=comments.get_model())
+ signals.post_save.connect(self.post_save_moderation, sender=comments.get_model())
+
+ def register(self, model_or_iterable, moderation_class):
+ """
+ Register a model or a list of models for comment moderation,
+ using a particular moderation class.
+
+ Raise ``AlreadyModerated`` if any of the models are already
+ registered.
+
+ """
+ if isinstance(model_or_iterable, ModelBase):
+ model_or_iterable = [model_or_iterable]
+ for model in model_or_iterable:
+ if model in self._registry:
+ raise AlreadyModerated("The model '%s' is already being moderated" % model._meta.module_name)
+ self._registry[model] = moderation_class(model)
+
+ def unregister(self, model_or_iterable):
+ """
+ Remove a model or a list of models from the list of models
+ whose comments will be moderated.
+
+ Raise ``NotModerated`` if any of the models are not currently
+ registered for moderation.
+
+ """
+ if isinstance(model_or_iterable, ModelBase):
+ model_or_iterable = [model_or_iterable]
+ for model in model_or_iterable:
+ if model not in self._registry:
+ raise NotModerated("The model '%s' is not currently being moderated" % model._meta.module_name)
+ del self._registry[model]
+
+ def pre_save_moderation(self, sender, instance, **kwargs):
+ """
+ Apply any necessary pre-save moderation steps to new
+ comments.
+
+ """
+ model = instance.content_type.model_class()
+ if instance.id or (model not in self._registry):
+ return
+ content_object = instance.content_object
+ moderation_class = self._registry[model]
+ if not moderation_class.allow(instance, content_object): # Comment will get deleted in post-save hook.
+ instance.moderation_disallowed = True
+ return
+ if moderation_class.moderate(instance, content_object):
+ instance.is_public = False
+
+ def post_save_moderation(self, sender, instance, **kwargs):
+ """
+ Apply any necessary post-save moderation steps to new
+ comments.
+
+ """
+ model = instance.content_type.model_class()
+ if model not in self._registry:
+ return
+ if hasattr(instance, 'moderation_disallowed'):
+ instance.delete()
+ return
+ self._registry[model].email(instance, instance.content_object)
+
+ def comments_open(self, obj):
+ """
+ Return ``True`` if new comments are being accepted for
+ ``obj``, ``False`` otherwise.
+
+ If no moderation rules have been registered for the model of
+ which ``obj`` is an instance, comments are assumed to be open
+ for that object.
+
+ """
+ model = obj.__class__
+ if model not in self._registry:
+ return True
+ return self._registry[model].comments_open(obj)
+
+ def comments_moderated(self, obj):
+ """
+ Return ``True`` if new comments for ``obj`` are being
+ automatically sent to moderation, ``False`` otherwise.
+
+ If no moderation rules have been registered for the model of
+ which ``obj`` is an instance, comments for that object are
+ assumed not to be moderated.
+
+ """
+ model = obj.__class__
+ if model not in self._registry:
+ return False
+ return self._registry[model].comments_moderated(obj)
+
+# Import this instance in your own code to use in registering
+# your models for moderation.
+moderator = Moderator()
View
2  docs/index.txt
@@ -82,7 +82,7 @@ Other batteries included
* :ref:`Authentication <topics-auth>`
* :ref:`Cache system <topics-cache>`
* :ref:`Conditional content processing <topics-conditional-processing>`
- * :ref:`Comments <ref-contrib-comments-index>`
+ * :ref:`Comments <ref-contrib-comments-index>` | :ref:`Moderation <ref-contrib-comments-moderation>` | :ref:`Custom comments <ref-contrib-comments-custom>`
* :ref:`Content types <ref-contrib-contenttypes>`
* :ref:`Cross Site Request Forgery protection <ref-contrib-csrf>`
* :ref:`Databrowse <ref-contrib-databrowse>`
View
1  docs/ref/contrib/comments/index.txt
@@ -216,3 +216,4 @@ More information
upgrade
custom
forms
+ moderation
View
15 tests/regressiontests/comment_tests/fixtures/comment_utils.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<django-objects version="1.0">
+ <object pk="1" model="comment_tests.entry">
+ <field type="CharField" name="title">ABC</field>
+ <field type="TextField" name="body">This is the body</field>
+ <field type="DateField" name="pub_date">2008-01-01</field>
+ <field type="BooleanField" name="enable_comments">True</field>
+ </object>
+ <object pk="2" model="comment_tests.entry">
+ <field type="CharField" name="title">XYZ</field>
+ <field type="TextField" name="body">Text here</field>
+ <field type="DateField" name="pub_date">2008-01-02</field>
+ <field type="BooleanField" name="enable_comments">False</field>
+ </object>
+</django-objects>
View
8 tests/regressiontests/comment_tests/models.py
@@ -20,3 +20,11 @@ class Article(models.Model):
def __str__(self):
return self.headline
+class Entry(models.Model):
+ title = models.CharField(max_length=250)
+ body = models.TextField()
+ pub_date = models.DateField()
+ enable_comments = models.BooleanField()
+
+ def __str__(self):
+ return self.title
View
1  tests/regressiontests/comment_tests/tests/__init__.py
@@ -86,3 +86,4 @@ def getValidData(self, obj):
from regressiontests.comment_tests.tests.templatetag_tests import *
from regressiontests.comment_tests.tests.comment_view_tests import *
from regressiontests.comment_tests.tests.moderation_view_tests import *
+from regressiontests.comment_tests.tests.comment_utils_moderators_tests import *
View
70 tests/regressiontests/comment_tests/tests/comment_utils_moderators_tests.py
@@ -0,0 +1,70 @@
+from regressiontests.comment_tests.tests import CommentTestCase, CT, Site
+from django.contrib.comments.models import Comment
+from django.contrib.comments.moderation import moderator, CommentModerator, AlreadyModerated
+from regressiontests.comment_tests.models import Entry
+from django.core import mail
+
+class EntryModerator1(CommentModerator):
+ email_notification = True
+
+class EntryModerator2(CommentModerator):
+ enable_field = 'enable_comments'
+
+class EntryModerator3(CommentModerator):
+ auto_close_field = 'pub_date'
+ close_after = 7
+
+class EntryModerator4(CommentModerator):
+ auto_moderate_field = 'pub_date'
+ moderate_after = 7
+
+class CommentUtilsModeratorTests(CommentTestCase):
+ fixtures = ["comment_utils.xml"]
+
+ def createSomeComments(self):
+ c1 = Comment.objects.create(
+ content_type = CT(Entry),
+ object_pk = "1",
+ user_name = "Joe Somebody",
+ user_email = "jsomebody@example.com",
+ user_url = "http://example.com/~joe/",
+ comment = "First!",
+ site = Site.objects.get_current(),
+ )
+ c2 = Comment.objects.create(
+ content_type = CT(Entry),
+ object_pk = "2",
+ user_name = "Joe the Plumber",
+ user_email = "joetheplumber@whitehouse.gov",
+ user_url = "http://example.com/~joe/",
+ comment = "Second!",
+ site = Site.objects.get_current(),
+ )
+ return c1, c2
+
+ def tearDown(self):
+ moderator.unregister(Entry)
+
+ def testRegisterExistingModel(self):
+ moderator.register(Entry, EntryModerator1)
+ self.assertRaises(AlreadyModerated, moderator.register, Entry, EntryModerator1)
+
+ def testEmailNotification(self):
+ moderator.register(Entry, EntryModerator1)
+ c1, c2 = self.createSomeComments()
+ self.assertEquals(len(mail.outbox), 2)
+
+ def testCommentsEnabled(self):
+ moderator.register(Entry, EntryModerator2)
+ c1, c2 = self.createSomeComments()
+ self.assertEquals(Comment.objects.all().count(), 1)
+
+ def testAutoCloseField(self):
+ moderator.register(Entry, EntryModerator3)
+ c1, c2 = self.createSomeComments()
+ self.assertEquals(Comment.objects.all().count(), 0)
+
+ def testAutoModerateField(self):
+ moderator.register(Entry, EntryModerator4)
+ c1, c2 = self.createSomeComments()
+ self.assertEquals(c2.is_public, False)
View
4 tests/runtests.py
@@ -110,6 +110,10 @@ def django_tests(verbosity, interactive, test_labels):
'django.middleware.common.CommonMiddleware',
)
settings.SITE_ID = 1
+ # For testing comment-utils, we require the MANAGERS attribute
+ # to be set, so that a test email is sent out which we catch
+ # in our tests.
+ settings.MANAGERS = ("admin@djangoproject.com",)
# Load all the ALWAYS_INSTALLED_APPS.
# (This import statement is intentionally delayed until after we
View
3  tests/templates/comments/comment_notification_email.txt
@@ -0,0 +1,3 @@
+A comment has been posted on {{ content_object }}.
+The comment reads as follows:
+{{ comment }}
Please sign in to comment.
Something went wrong with that request. Please try again.