Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

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
Jacob Kaplan-Moss authored March 23, 2009
442  django/contrib/comments/moderation.py
... ...
@@ -0,0 +1,442 @@
  1
+"""
  2
+A generic comment-moderation system which allows configuration of
  3
+moderation options on a per-model basis.
  4
+
  5
+Originally part of django-comment-utils, by James Bennett.
  6
+
  7
+To use, do two things:
  8
+
  9
+1. Create or import a subclass of ``CommentModerator`` defining the
  10
+   options you want.
  11
+
  12
+2. Import ``moderator`` from this module and register one or more
  13
+   models, passing the models and the ``CommentModerator`` options
  14
+   class you want to use.
  15
+
  16
+
  17
+Example
  18
+-------
  19
+
  20
+First, we define a simple model class which might represent entries in
  21
+a weblog::
  22
+
  23
+    from django.db import models
  24
+
  25
+    class Entry(models.Model):
  26
+        title = models.CharField(maxlength=250)
  27
+        body = models.TextField()
  28
+        pub_date = models.DateField()
  29
+        enable_comments = models.BooleanField()
  30
+
  31
+Then we create a ``CommentModerator`` subclass specifying some
  32
+moderation options::
  33
+
  34
+    from django.contrib.comments.moderation import CommentModerator, moderator
  35
+
  36
+    class EntryModerator(CommentModerator):
  37
+        email_notification = True
  38
+        enable_field = 'enable_comments'
  39
+
  40
+And finally register it for moderation::
  41
+
  42
+    moderator.register(Entry, EntryModerator)
  43
+
  44
+This sample class would apply several moderation steps to each new
  45
+comment submitted on an Entry:
  46
+
  47
+* If the entry's ``enable_comments`` field is set to ``False``, the
  48
+  comment will be rejected (immediately deleted).
  49
+
  50
+* If the comment is successfully posted, an email notification of the
  51
+  comment will be sent to site staff.
  52
+
  53
+For a full list of built-in moderation options and other
  54
+configurability, see the documentation for the ``CommentModerator``
  55
+class.
  56
+
  57
+Several example subclasses of ``CommentModerator`` are provided in
  58
+`django-comment-utils`_, both to provide common moderation options and to
  59
+demonstrate some of the ways subclasses can customize moderation
  60
+behavior.
  61
+
  62
+.. _`django-comment-utils`: http://code.google.com/p/django-comment-utils/
  63
+"""
  64
+
  65
+import datetime
  66
+
  67
+from django.conf import settings
  68
+from django.core.mail import send_mail
  69
+from django.db.models import signals
  70
+from django.db.models.base import ModelBase
  71
+from django.template import Context, loader
  72
+from django.contrib import comments
  73
+from django.contrib.sites.models import Site
  74
+
  75
+class AlreadyModerated(Exception):
  76
+    """
  77
+    Raised when a model which is already registered for moderation is
  78
+    attempting to be registered again.
  79
+
  80
+    """
  81
+    pass
  82
+
  83
+class NotModerated(Exception):
  84
+    """
  85
+    Raised when a model which is not registered for moderation is
  86
+    attempting to be unregistered.
  87
+
  88
+    """
  89
+    pass
  90
+
  91
+class CommentModerator(object):
  92
+    """
  93
+    Encapsulates comment-moderation options for a given model.
  94
+
  95
+    This class is not designed to be used directly, since it doesn't
  96
+    enable any of the available moderation options. Instead, subclass
  97
+    it and override attributes to enable different options::
  98
+
  99
+    ``auto_close_field``
  100
+        If this is set to the name of a ``DateField`` or
  101
+        ``DateTimeField`` on the model for which comments are
  102
+        being moderated, new comments for objects of that model
  103
+        will be disallowed (immediately deleted) when a certain
  104
+        number of days have passed after the date specified in
  105
+        that field. Must be used in conjunction with
  106
+        ``close_after``, which specifies the number of days past
  107
+        which comments should be disallowed. Default value is
  108
+        ``None``.
  109
+
  110
+    ``auto_moderate_field``
  111
+        Like ``auto_close_field``, but instead of outright
  112
+        deleting new comments when the requisite number of days
  113
+        have elapsed, it will simply set the ``is_public`` field
  114
+        of new comments to ``False`` before saving them. Must be
  115
+        used in conjunction with ``moderate_after``, which
  116
+        specifies the number of days past which comments should be
  117
+        moderated. Default value is ``None``.
  118
+
  119
+    ``close_after``
  120
+        If ``auto_close_field`` is used, this must specify the
  121
+        number of days past the value of the field specified by
  122
+        ``auto_close_field`` after which new comments for an
  123
+        object should be disallowed. Default value is ``None``.
  124
+
  125
+    ``email_notification``
  126
+        If ``True``, any new comment on an object of this model
  127
+        which survives moderation will generate an email to site
  128
+        staff. Default value is ``False``.
  129
+
  130
+    ``enable_field``
  131
+        If this is set to the name of a ``BooleanField`` on the
  132
+        model for which comments are being moderated, new comments
  133
+        on objects of that model will be disallowed (immediately
  134
+        deleted) whenever the value of that field is ``False`` on
  135
+        the object the comment would be attached to. Default value
  136
+        is ``None``.
  137
+
  138
+    ``moderate_after``
  139
+        If ``auto_moderate_field`` is used, this must specify the number
  140
+        of days past the value of the field specified by
  141
+        ``auto_moderate_field`` after which new comments for an
  142
+        object should be marked non-public. Default value is
  143
+        ``None``.
  144
+
  145
+    Most common moderation needs can be covered by changing these
  146
+    attributes, but further customization can be obtained by
  147
+    subclassing and overriding the following methods. Each method will
  148
+    be called with two arguments: ``comment``, which is the comment
  149
+    being submitted, and ``content_object``, which is the object the
  150
+    comment will be attached to::
  151
+
  152
+    ``allow``
  153
+        Should return ``True`` if the comment should be allowed to
  154
+        post on the content object, and ``False`` otherwise (in
  155
+        which case the comment will be immediately deleted).
  156
+
  157
+    ``email``
  158
+        If email notification of the new comment should be sent to
  159
+        site staff or moderators, this method is responsible for
  160
+        sending the email.
  161
+
  162
+    ``moderate``
  163
+        Should return ``True`` if the comment should be moderated
  164
+        (in which case its ``is_public`` field will be set to
  165
+        ``False`` before saving), and ``False`` otherwise (in
  166
+        which case the ``is_public`` field will not be changed).
  167
+
  168
+    Subclasses which want to introspect the model for which comments
  169
+    are being moderated can do so through the attribute ``_model``,
  170
+    which will be the model class.
  171
+
  172
+    """
  173
+    auto_close_field = None
  174
+    auto_moderate_field = None
  175
+    close_after = None
  176
+    email_notification = False
  177
+    enable_field = None
  178
+    moderate_after = None
  179
+
  180
+    def __init__(self, model):
  181
+        self._model = model
  182
+
  183
+    def _get_delta(self, now, then):
  184
+        """
  185
+        Internal helper which will return a ``datetime.timedelta``
  186
+        representing the time between ``now`` and ``then``. Assumes
  187
+        ``now`` is a ``datetime.date`` or ``datetime.datetime`` later
  188
+        than ``then``.
  189
+
  190
+        If ``now`` and ``then`` are not of the same type due to one of
  191
+        them being a ``datetime.date`` and the other being a
  192
+        ``datetime.datetime``, both will be coerced to
  193
+        ``datetime.date`` before calculating the delta.
  194
+
  195
+        """
  196
+        if now.__class__ is not then.__class__:
  197
+            now = datetime.date(now.year, now.month, now.day)
  198
+            then = datetime.date(then.year, then.month, then.day)
  199
+        if now < then:
  200
+            raise ValueError("Cannot determine moderation rules because date field is set to a value in the future")
  201
+        return now - then
  202
+
  203
+    def allow(self, comment, content_object):
  204
+        """
  205
+        Determine whether a given comment is allowed to be posted on
  206
+        a given object.
  207
+
  208
+        Return ``True`` if the comment should be allowed, ``False
  209
+        otherwise.
  210
+
  211
+        """
  212
+        if self.enable_field:
  213
+            if not getattr(content_object, self.enable_field):
  214
+                return False
  215
+        if self.auto_close_field and self.close_after:
  216
+            if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_close_field)).days >= self.close_after:
  217
+                return False
  218
+        return True
  219
+
  220
+    def moderate(self, comment, content_object):
  221
+        """
  222
+        Determine whether a given comment on a given object should be
  223
+        allowed to show up immediately, or should be marked non-public
  224
+        and await approval.
  225
+
  226
+        Return ``True`` if the comment should be moderated (marked
  227
+        non-public), ``False`` otherwise.
  228
+
  229
+        """
  230
+        if self.auto_moderate_field and self.moderate_after:
  231
+            if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_moderate_field)).days >= self.moderate_after:
  232
+                return True
  233
+        return False
  234
+
  235
+    def comments_open(self, obj):
  236
+        """
  237
+        Return ``True`` if new comments are being accepted for
  238
+        ``obj``, ``False`` otherwise.
  239
+
  240
+        The algorithm for determining this is as follows:
  241
+
  242
+        1. If ``enable_field`` is set and the relevant field on
  243
+           ``obj`` contains a false value, comments are not open.
  244
+
  245
+        2. If ``close_after`` is set and the relevant date field on
  246
+           ``obj`` is far enough in the past, comments are not open.
  247
+
  248
+        3. If neither of the above checks determined that comments are
  249
+           not open, comments are open.
  250
+
  251
+        """
  252
+        if self.enable_field:
  253
+            if not getattr(obj, self.enable_field):
  254
+                return False
  255
+        if self.auto_close_field and self.close_after:
  256
+            if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_close_field)).days >= self.close_after:
  257
+                return False
  258
+        return True
  259
+
  260
+    def comments_moderated(self, obj):
  261
+        """
  262
+        Return ``True`` if new comments for ``obj`` are being
  263
+        automatically sent to moderation, ``False`` otherwise.
  264
+
  265
+        The algorithm for determining this is as follows:
  266
+
  267
+        1. If ``moderate_field`` is set and the relevant field on
  268
+           ``obj`` contains a true value, comments are moderated.
  269
+
  270
+        2. If ``moderate_after`` is set and the relevant date field on
  271
+           ``obj`` is far enough in the past, comments are moderated.
  272
+
  273
+        3. If neither of the above checks decided that comments are
  274
+           moderated, comments are not moderated.
  275
+
  276
+        """
  277
+        if self.moderate_field:
  278
+            if getattr(obj, self.moderate_field):
  279
+                return True
  280
+        if self.auto_moderate_field and self.moderate_after:
  281
+            if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_moderate_field)).days >= self.moderate_after:
  282
+                return True
  283
+        return False
  284
+
  285
+    def email(self, comment, content_object):
  286
+        """
  287
+        Send email notification of a new comment to site staff when email
  288
+        notifications have been requested.
  289
+
  290
+        """
  291
+        if not self.email_notification:
  292
+            return
  293
+        recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS]
  294
+        t = loader.get_template('comments/comment_notification_email.txt')
  295
+        c = Context({ 'comment': comment,
  296
+                      'content_object': content_object })
  297
+        subject = '[%s] New comment posted on "%s"' % (Site.objects.get_current().name,
  298
+                                                          content_object)
  299
+        message = t.render(c)
  300
+        send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True)
  301
+
  302
+class Moderator(object):
  303
+    """
  304
+    Handles moderation of a set of models.
  305
+
  306
+    An instance of this class will maintain a list of one or more
  307
+    models registered for comment moderation, and their associated
  308
+    moderation classes, and apply moderation to all incoming comments.
  309
+
  310
+    To register a model, obtain an instance of ``CommentModerator``
  311
+    (this module exports one as ``moderator``), and call its
  312
+    ``register`` method, passing the model class and a moderation
  313
+    class (which should be a subclass of ``CommentModerator``). Note
  314
+    that both of these should be the actual classes, not instances of
  315
+    the classes.
  316
+
  317
+    To cease moderation for a model, call the ``unregister`` method,
  318
+    passing the model class.
  319
+
  320
+    For convenience, both ``register`` and ``unregister`` can also
  321
+    accept a list of model classes in place of a single model; this
  322
+    allows easier registration of multiple models with the same
  323
+    ``CommentModerator`` class.
  324
+
  325
+    The actual moderation is applied in two phases: one prior to
  326
+    saving a new comment, and the other immediately after saving. The
  327
+    pre-save moderation may mark a comment as non-public or mark it to
  328
+    be removed; the post-save moderation may delete a comment which
  329
+    was disallowed (there is currently no way to prevent the comment
  330
+    being saved once before removal) and, if the comment is still
  331
+    around, will send any notification emails the comment generated.
  332
+
  333
+    """
  334
+    def __init__(self):
  335
+        self._registry = {}
  336
+        self.connect()
  337
+
  338
+    def connect(self):
  339
+        """
  340
+        Hook up the moderation methods to pre- and post-save signals
  341
+        from the comment models.
  342
+
  343
+        """
  344
+        signals.pre_save.connect(self.pre_save_moderation, sender=comments.get_model())
  345
+        signals.post_save.connect(self.post_save_moderation, sender=comments.get_model())
  346
+
  347
+    def register(self, model_or_iterable, moderation_class):
  348
+        """
  349
+        Register a model or a list of models for comment moderation,
  350
+        using a particular moderation class.
  351
+
  352
+        Raise ``AlreadyModerated`` if any of the models are already
  353
+        registered.
  354
+
  355
+        """
  356
+        if isinstance(model_or_iterable, ModelBase):
  357
+            model_or_iterable = [model_or_iterable]
  358
+        for model in model_or_iterable:
  359
+            if model in self._registry:
  360
+                raise AlreadyModerated("The model '%s' is already being moderated" % model._meta.module_name)
  361
+            self._registry[model] = moderation_class(model)
  362
+
  363
+    def unregister(self, model_or_iterable):
  364
+        """
  365
+        Remove a model or a list of models from the list of models
  366
+        whose comments will be moderated.
  367
+
  368
+        Raise ``NotModerated`` if any of the models are not currently
  369
+        registered for moderation.
  370
+
  371
+        """
  372
+        if isinstance(model_or_iterable, ModelBase):
  373
+            model_or_iterable = [model_or_iterable]
  374
+        for model in model_or_iterable:
  375
+            if model not in self._registry:
  376
+                raise NotModerated("The model '%s' is not currently being moderated" % model._meta.module_name)
  377
+            del self._registry[model]
  378
+
  379
+    def pre_save_moderation(self, sender, instance, **kwargs):
  380
+        """
  381
+        Apply any necessary pre-save moderation steps to new
  382
+        comments.
  383
+
  384
+        """
  385
+        model = instance.content_type.model_class()
  386
+        if instance.id or (model not in self._registry):
  387
+            return
  388
+        content_object = instance.content_object
  389
+        moderation_class = self._registry[model]
  390
+        if not moderation_class.allow(instance, content_object): # Comment will get deleted in post-save hook.
  391
+            instance.moderation_disallowed = True
  392
+            return
  393
+        if moderation_class.moderate(instance, content_object):
  394
+            instance.is_public = False
  395
+
  396
+    def post_save_moderation(self, sender, instance, **kwargs):
  397
+        """
  398
+        Apply any necessary post-save moderation steps to new
  399
+        comments.
  400
+
  401
+        """
  402
+        model = instance.content_type.model_class()
  403
+        if model not in self._registry:
  404
+            return
  405
+        if hasattr(instance, 'moderation_disallowed'):
  406
+            instance.delete()
  407
+            return
  408
+        self._registry[model].email(instance, instance.content_object)
  409
+
  410
+    def comments_open(self, obj):
  411
+        """
  412
+        Return ``True`` if new comments are being accepted for
  413
+        ``obj``, ``False`` otherwise.
  414
+
  415
+        If no moderation rules have been registered for the model of
  416
+        which ``obj`` is an instance, comments are assumed to be open
  417
+        for that object.
  418
+
  419
+        """
  420
+        model = obj.__class__
  421
+        if model not in self._registry:
  422
+            return True
  423
+        return self._registry[model].comments_open(obj)
  424
+
  425
+    def comments_moderated(self, obj):
  426
+        """
  427
+        Return ``True`` if new comments for ``obj`` are being
  428
+        automatically sent to moderation, ``False`` otherwise.
  429
+
  430
+        If no moderation rules have been registered for the model of
  431
+        which ``obj`` is an instance, comments for that object are
  432
+        assumed not to be moderated.
  433
+
  434
+        """
  435
+        model = obj.__class__
  436
+        if model not in self._registry:
  437
+            return False
  438
+        return self._registry[model].comments_moderated(obj)
  439
+
  440
+# Import this instance in your own code to use in registering
  441
+# your models for moderation.
  442
+moderator = Moderator()
2  docs/index.txt
@@ -82,7 +82,7 @@ Other batteries included
82 82
     * :ref:`Authentication <topics-auth>`
83 83
     * :ref:`Cache system <topics-cache>`
84 84
     * :ref:`Conditional content processing <topics-conditional-processing>`
85  
-    * :ref:`Comments <ref-contrib-comments-index>`
  85
+    * :ref:`Comments <ref-contrib-comments-index>` | :ref:`Moderation <ref-contrib-comments-moderation>` | :ref:`Custom comments <ref-contrib-comments-custom>`
86 86
     * :ref:`Content types <ref-contrib-contenttypes>`
87 87
     * :ref:`Cross Site Request Forgery protection <ref-contrib-csrf>`
88 88
     * :ref:`Databrowse <ref-contrib-databrowse>`
1  docs/ref/contrib/comments/index.txt
@@ -216,3 +216,4 @@ More information
216 216
    upgrade
217 217
    custom
218 218
    forms
  219
+   moderation
15  tests/regressiontests/comment_tests/fixtures/comment_utils.xml
... ...
@@ -0,0 +1,15 @@
  1
+<?xml version="1.0" encoding="utf-8"?>
  2
+<django-objects version="1.0">
  3
+  <object pk="1" model="comment_tests.entry">
  4
+      <field type="CharField" name="title">ABC</field>
  5
+      <field type="TextField" name="body">This is the body</field>
  6
+      <field type="DateField" name="pub_date">2008-01-01</field>
  7
+      <field type="BooleanField" name="enable_comments">True</field>
  8
+  </object>
  9
+  <object pk="2" model="comment_tests.entry">
  10
+      <field type="CharField" name="title">XYZ</field>
  11
+      <field type="TextField" name="body">Text here</field>
  12
+      <field type="DateField" name="pub_date">2008-01-02</field>
  13
+      <field type="BooleanField" name="enable_comments">False</field>
  14
+  </object>
  15
+</django-objects>
8  tests/regressiontests/comment_tests/models.py
@@ -20,3 +20,11 @@ class Article(models.Model):
20 20
     def __str__(self):
21 21
         return self.headline
22 22
 
  23
+class Entry(models.Model):
  24
+    title = models.CharField(max_length=250)
  25
+    body = models.TextField()
  26
+    pub_date = models.DateField()
  27
+    enable_comments = models.BooleanField()
  28
+
  29
+    def __str__(self):
  30
+        return self.title
1  tests/regressiontests/comment_tests/tests/__init__.py
@@ -86,3 +86,4 @@ def getValidData(self, obj):
86 86
 from regressiontests.comment_tests.tests.templatetag_tests import *
87 87
 from regressiontests.comment_tests.tests.comment_view_tests import *
88 88
 from regressiontests.comment_tests.tests.moderation_view_tests import *
  89
+from regressiontests.comment_tests.tests.comment_utils_moderators_tests import *
70  tests/regressiontests/comment_tests/tests/comment_utils_moderators_tests.py
... ...
@@ -0,0 +1,70 @@
  1
+from regressiontests.comment_tests.tests import CommentTestCase, CT, Site
  2
+from django.contrib.comments.models import Comment
  3
+from django.contrib.comments.moderation import moderator, CommentModerator, AlreadyModerated
  4
+from regressiontests.comment_tests.models import Entry
  5
+from django.core import mail
  6
+
  7
+class EntryModerator1(CommentModerator):
  8
+    email_notification = True
  9
+
  10
+class EntryModerator2(CommentModerator):
  11
+    enable_field = 'enable_comments'
  12
+
  13
+class EntryModerator3(CommentModerator):
  14
+    auto_close_field = 'pub_date'
  15
+    close_after = 7
  16
+
  17
+class EntryModerator4(CommentModerator):
  18
+    auto_moderate_field = 'pub_date'
  19
+    moderate_after = 7
  20
+
  21
+class CommentUtilsModeratorTests(CommentTestCase):
  22
+    fixtures = ["comment_utils.xml"]
  23
+
  24
+    def createSomeComments(self):
  25
+        c1 = Comment.objects.create(
  26
+            content_type = CT(Entry),
  27
+            object_pk = "1",
  28
+            user_name = "Joe Somebody",
  29
+            user_email = "jsomebody@example.com",
  30
+            user_url = "http://example.com/~joe/",
  31
+            comment = "First!",
  32
+            site = Site.objects.get_current(),
  33
+        )
  34
+        c2 = Comment.objects.create(
  35
+            content_type = CT(Entry),
  36
+            object_pk = "2",
  37
+            user_name = "Joe the Plumber",
  38
+            user_email = "joetheplumber@whitehouse.gov",
  39
+            user_url = "http://example.com/~joe/",
  40
+            comment = "Second!",
  41
+            site = Site.objects.get_current(),
  42
+        )
  43
+        return c1, c2
  44
+
  45
+    def tearDown(self):
  46
+        moderator.unregister(Entry)
  47
+
  48
+    def testRegisterExistingModel(self):
  49
+        moderator.register(Entry, EntryModerator1)
  50
+        self.assertRaises(AlreadyModerated, moderator.register, Entry, EntryModerator1)
  51
+
  52
+    def testEmailNotification(self):
  53
+        moderator.register(Entry, EntryModerator1)
  54
+        c1, c2 = self.createSomeComments()
  55
+        self.assertEquals(len(mail.outbox), 2)
  56
+
  57
+    def testCommentsEnabled(self):
  58
+        moderator.register(Entry, EntryModerator2)
  59
+        c1, c2 = self.createSomeComments()
  60
+        self.assertEquals(Comment.objects.all().count(), 1)
  61
+
  62
+    def testAutoCloseField(self):
  63
+        moderator.register(Entry, EntryModerator3)
  64
+        c1, c2 = self.createSomeComments()
  65
+        self.assertEquals(Comment.objects.all().count(), 0)
  66
+
  67
+    def testAutoModerateField(self):
  68
+        moderator.register(Entry, EntryModerator4)
  69
+        c1, c2 = self.createSomeComments()
  70
+        self.assertEquals(c2.is_public, False)
4  tests/runtests.py
@@ -110,6 +110,10 @@ def django_tests(verbosity, interactive, test_labels):
110 110
         'django.middleware.common.CommonMiddleware',
111 111
     )
112 112
     settings.SITE_ID = 1
  113
+    # For testing comment-utils, we require the MANAGERS attribute
  114
+    # to be set, so that a test email is sent out which we catch
  115
+    # in our tests.
  116
+    settings.MANAGERS = ("admin@djangoproject.com",)
113 117
 
114 118
     # Load all the ALWAYS_INSTALLED_APPS.
115 119
     # (This import statement is intentionally delayed until after we
3  tests/templates/comments/comment_notification_email.txt
... ...
@@ -0,0 +1,3 @@
  1
+A comment has been posted on {{ content_object }}.
  2
+The comment reads as follows:
  3
+{{ comment }}

0 notes on commit f0560df

Please sign in to comment.
Something went wrong with that request. Please try again.