diff --git a/django/contrib/comments/moderation.py b/django/contrib/comments/moderation.py new file mode 100644 index 0000000000..9dd6e54732 --- /dev/null +++ b/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() diff --git a/docs/index.txt b/docs/index.txt index 4b295558a8..715057d4f7 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -82,7 +82,7 @@ Other batteries included * :ref:`Authentication ` * :ref:`Cache system ` * :ref:`Conditional content processing ` - * :ref:`Comments ` + * :ref:`Comments ` | :ref:`Moderation ` | :ref:`Custom comments ` * :ref:`Content types ` * :ref:`Cross Site Request Forgery protection ` * :ref:`Databrowse ` diff --git a/docs/ref/contrib/comments/index.txt b/docs/ref/contrib/comments/index.txt index f3a59bbbd4..10a7dcf00f 100644 --- a/docs/ref/contrib/comments/index.txt +++ b/docs/ref/contrib/comments/index.txt @@ -216,3 +216,4 @@ More information upgrade custom forms + moderation \ No newline at end of file diff --git a/tests/regressiontests/comment_tests/fixtures/comment_utils.xml b/tests/regressiontests/comment_tests/fixtures/comment_utils.xml new file mode 100644 index 0000000000..a39bbf63e1 --- /dev/null +++ b/tests/regressiontests/comment_tests/fixtures/comment_utils.xml @@ -0,0 +1,15 @@ + + + + ABC + This is the body + 2008-01-01 + True + + + XYZ + Text here + 2008-01-02 + False + + diff --git a/tests/regressiontests/comment_tests/models.py b/tests/regressiontests/comment_tests/models.py index 28022e2848..62f416882c 100644 --- a/tests/regressiontests/comment_tests/models.py +++ b/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 diff --git a/tests/regressiontests/comment_tests/tests/__init__.py b/tests/regressiontests/comment_tests/tests/__init__.py index 09026aa010..449fea471d 100644 --- a/tests/regressiontests/comment_tests/tests/__init__.py +++ b/tests/regressiontests/comment_tests/tests/__init__.py @@ -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 * diff --git a/tests/regressiontests/comment_tests/tests/comment_utils_moderators_tests.py b/tests/regressiontests/comment_tests/tests/comment_utils_moderators_tests.py new file mode 100644 index 0000000000..4fe8b8ae12 --- /dev/null +++ b/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) diff --git a/tests/runtests.py b/tests/runtests.py index bd7f59bdf0..f556246c90 100755 --- a/tests/runtests.py +++ b/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 diff --git a/tests/templates/comments/comment_notification_email.txt b/tests/templates/comments/comment_notification_email.txt new file mode 100644 index 0000000000..63f149392e --- /dev/null +++ b/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 }}