Commit f0560dfd authored by Jacob Kaplan-Moss's avatar Jacob Kaplan-Moss
Browse files

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
parent 44f30802
Loading
Loading
Loading
Loading
+442 −0
Original line number Diff line number Diff line
"""
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()
+1 −1
Original line number Diff line number Diff line
@@ -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>`
+1 −0
Original line number Diff line number Diff line
@@ -216,3 +216,4 @@ More information
   upgrade
   custom
   forms
   moderation
 No newline at end of file
+15 −0
Original line number Diff line number Diff line
<?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>
+8 −0
Original line number Diff line number Diff line
@@ -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
Loading