Fixed #9282: added a generic comment moderation toolkit. See the documentation 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
This commit is contained in:
parent
44f3080226
commit
f0560dfdb2
|
@ -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()
|
|
@ -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>`
|
||||
|
|
|
@ -216,3 +216,4 @@ More information
|
|||
upgrade
|
||||
custom
|
||||
forms
|
||||
moderation
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -86,3 +86,4 @@ from regressiontests.comment_tests.tests.comment_form_tests import *
|
|||
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 *
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
A comment has been posted on {{ content_object }}.
|
||||
The comment reads as follows:
|
||||
{{ comment }}
|
Loading…
Reference in New Issue