PageRenderTime 39ms CodeModel.GetById 10ms app.highlight 22ms RepoModel.GetById 1ms app.codeStats 0ms

/comment_utils/moderation.py

http://django-comment-utils.googlecode.com/
Python | 559 lines | 454 code | 22 blank | 83 comment | 34 complexity | 27d90ac4f9987cecde58e343499de781 MD5 | raw file
  1"""
  2A generic comment-moderation system which allows configuration of
  3moderation options on a per-model basis.
  4
  5To use, do two things:
  6
  71. Create or import a subclass of ``CommentModerator`` defining the
  8   options you want.
  9
 102. Import ``moderator`` from this module and register one or more
 11   models, passing the models and the ``CommentModerator`` options
 12   class you want to use.
 13
 14
 15Example
 16-------
 17
 18First, we define a simple model class which might represent entries in
 19a weblog::
 20    
 21    from django.db import models
 22    
 23    class Entry(models.Model):
 24        title = models.CharField(maxlength=250)
 25        body = models.TextField()
 26        pub_date = models.DateField()
 27        enable_comments = models.BooleanField()
 28
 29Then we create a ``CommentModerator`` subclass specifying some
 30moderation options::
 31    
 32    from comment_utils.moderation import CommentModerator, moderator
 33    
 34    class EntryModerator(CommentModerator):
 35        akismet = True
 36        email_notification = True
 37        enable_field = 'enable_comments'
 38
 39And finally register it for moderation::
 40    
 41    moderator.register(Entry, EntryModerator)
 42
 43This sample class would apply several moderation steps to each new
 44comment submitted on an Entry:
 45
 46* If the entry's ``enable_comments`` field is set to ``False``, the
 47  comment will be rejected (immediately deleted).
 48
 49* If the comment is allowed to post, it will be submitted to an
 50  Akismet spam check (requires the Python Akismet module and an
 51  Akismet API key); if Akismet thinks the comment is spam, its
 52  ``is_public`` field will be set to ``False``.
 53
 54* If the comment is successfully posted, an email notification of the
 55  comment will be sent to site staff.
 56
 57For a full list of built-in moderation options and other
 58configurability, see the documentation for the ``CommentModerator``
 59class.
 60
 61Several example subclasses of ``CommentModerator`` are provided in
 62this module as well, both to provide common moderation options and to
 63demonstrate some of the ways subclasses can customize moderation
 64behavior.
 65
 66"""
 67
 68
 69import datetime
 70
 71from django.conf import settings
 72from django.core.mail import send_mail
 73from django.db.models import signals
 74from django.db.models.base import ModelBase
 75from django.template import Context, loader
 76from django.contrib.comments.models import Comment, FreeComment
 77from django.contrib.sites.models import Site
 78
 79
 80class AlreadyModerated(Exception):
 81    """
 82    Raised when a model which is already registered for moderation is
 83    attempting to be registered again.
 84    
 85    """
 86    pass
 87
 88
 89class NotModerated(Exception):
 90    """
 91    Raised when a model which is not registered for moderation is
 92    attempting to be unregistered.
 93    
 94    """
 95    pass
 96
 97
 98class CommentModerator(object):
 99    """
100    Encapsulates comment-moderation options for a given model.
101    
102    This class is not designed to be used directly, since it doesn't
103    enable any of the available moderation options. Instead, subclass
104    it and override attributes to enable different options::
105    
106    ``akismet``
107        If ``True``, comments will be submitted to an Akismet spam
108        check and, if Akismet thinks they're spam, will have their
109        ``is_public`` field set to ``False`` before saving. If
110        this is enabled, you will need to have the Python Akismet
111        module installed, and you will need to add the setting
112        ``AKISMET_API_KEY`` to your Django settings file; the
113        value of this setting should be a valid Akismet API
114        key. Default value is ``False``.
115    
116    ``auto_close_field``
117        If this is set to the name of a ``DateField`` or
118        ``DateTimeField`` on the model for which comments are
119        being moderated, new comments for objects of that model
120        will be disallowed (immediately deleted) when a certain
121        number of days have passed after the date specified in
122        that field. Must be used in conjunction with
123        ``close_after``, which specifies the number of days past
124        which comments should be disallowed. Default value is
125        ``None``.
126    
127    ``auto_moderate_field``
128        Like ``auto_close_field``, but instead of outright
129        deleting new comments when the requisite number of days
130        have elapsed, it will simply set the ``is_public`` field
131        of new comments to ``False`` before saving them. Must be
132        used in conjunction with ``moderate_after``, which
133        specifies the number of days past which comments should be
134        moderated. Default value is ``None``.
135    
136    ``close_after``
137        If ``auto_close_field`` is used, this must specify the
138        number of days past the value of the field specified by
139        ``auto_close_field`` after which new comments for an
140        object should be disallowed. Default value is ``None``.
141    
142    ``email_notification``
143        If ``True``, any new comment on an object of this model
144        which survives moderation will generate an email to site
145        staff. Default value is ``False``.
146    
147    ``enable_field``
148        If this is set to the name of a ``BooleanField`` on the
149        model for which comments are being moderated, new comments
150        on objects of that model will be disallowed (immediately
151        deleted) whenever the value of that field is ``False`` on
152        the object the comment would be attached to. Default value
153        is ``None``.
154    
155    ``moderate_after``
156        If ``auto_moderate`` is used, this must specify the number
157        of days past the value of the field specified by
158        ``auto_moderate_field`` after which new comments for an
159        object should be marked non-public. Default value is
160        ``None``.
161    
162    Most common moderation needs can be covered by changing these
163    attributes, but further customization can be obtained by
164    subclassing and overriding the following methods. Each method will
165    be called with two arguments: ``comment``, which is the comment
166    being submitted, and ``content_object``, which is the object the
167    comment will be attached to::
168    
169    ``allow``
170        Should return ``True`` if the comment should be allowed to
171        post on the content object, and ``False`` otherwise (in
172        which case the comment will be immediately deleted).
173    
174    ``email``
175        If email notification of the new comment should be sent to
176        site staff or moderators, this method is responsible for
177        sending the email.
178    
179    ``moderate``
180        Should return ``True`` if the comment should be moderated
181        (in which case its ``is_public`` field will be set to
182        ``False`` before saving), and ``False`` otherwise (in
183        which case the ``is_public`` field will not be changed).
184    
185    Subclasses which want to introspect the model for which comments
186    are being moderated can do so through the attribute ``_model``,
187    which will be the model class.
188    
189    """
190    akismet = False
191    auto_close_field = None
192    auto_moderate_field = None
193    close_after = None
194    email_notification = False
195    enable_field = None
196    moderate_after = None
197    
198    def __init__(self, model):
199        self._model = model
200    
201    def _get_delta(self, now, then):
202        """
203        Internal helper which will return a ``datetime.timedelta``
204        representing the time between ``now`` and ``then``. Assumes
205        ``now`` is a ``datetime.date`` or ``datetime.datetime`` later
206        than ``then``.
207        
208        If ``now`` and ``then`` are not of the same type due to one of
209        them being a ``datetime.date`` and the other being a
210        ``datetime.datetime``, both will be coerced to
211        ``datetime.date`` before calculating the delta.
212        
213        """
214        if now.__class__ is not then.__class__:
215            now = datetime.date(now.year, now.month, now.day)
216            then = datetime.date(then.year, then.month, then.day)
217        if now < then:
218            raise ValueError("Cannot determine moderation rules because date field is set to a value in the future")
219        return now - then
220        
221    def allow(self, comment, content_object):
222        """
223        Determine whether a given comment is allowed to be posted on
224        a given object.
225        
226        Return ``True`` if the comment should be allowed, ``False
227        otherwise.
228        
229        """
230        if self.enable_field:
231            if not getattr(content_object, self.enable_field):
232                return False
233        if self.auto_close_field and self.close_after:
234            if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_close_field)).days >= self.close_after:
235                return False
236        return True
237    
238    def moderate(self, comment, content_object):
239        """
240        Determine whether a given comment on a given object should be
241        allowed to show up immediately, or should be marked non-public
242        and await approval.
243        
244        Return ``True`` if the comment should be moderated (marked
245        non-public), ``False`` otherwise.
246        
247        """
248        if self.auto_moderate_field and self.moderate_after:
249            if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_moderate_field)).days >= self.moderate_after:
250                return True
251        if self.akismet:
252            from akismet import Akismet
253            from django.utils.encoding import smart_str
254            akismet_api = Akismet(key=settings.AKISMET_API_KEY,
255                                  blog_url='http://%s/' % Site.objects.get_current().domain)
256            if akismet_api.verify_key():
257                akismet_data = { 'comment_type': 'comment',
258                                 'referrer': '',
259                                 'user_ip': comment.ip_address,
260                                 'user_agent': '' }
261                if akismet_api.comment_check(smart_str(comment.comment), data=akismet_data, build_data=True):
262                    return True
263        return False
264    
265    def comments_open(self, obj):
266        """
267        Return ``True`` if new comments are being accepted for
268        ``obj``, ``False`` otherwise.
269        
270        The algorithm for determining this is as follows:
271        
272        1. If ``enable_field`` is set and the relevant field on
273           ``obj`` contains a false value, comments are not open.
274        
275        2. If ``close_after`` is set and the relevant date field on
276           ``obj`` is far enough in the past, comments are not open.
277        
278        3. If neither of the above checks determined that comments are
279           not open, comments are open.
280        
281        """
282        if self.enable_field:
283            if not getattr(obj, self.enable_field):
284                return False
285        if self.auto_close_field and self.close_after:
286            if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_close_field)).days >= self.close_after:
287                return False
288        return True
289    
290    def comments_moderated(self, obj):
291        """
292        Return ``True`` if new comments for ``obj`` are being
293        automatically sent to moderation, ``False`` otherwise.
294        
295        The algorithm for determining this is as follows:
296        
297        1. If ``moderate_field`` is set and the relevant field on
298           ``obj`` contains a true value, comments are moderated.
299        
300        2. If ``moderate_after`` is set and the relevant date field on
301           ``obj`` is far enough in the past, comments are moderated.
302        
303        3. If neither of the above checks decided that comments are
304           moderated, comments are not moderated.
305        
306        """
307        if self.moderate_field:
308            if getattr(obj, self.moderate_field):
309                return True
310        if self.auto_moderate_field and self.moderate_after:
311            if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_moderate_field)).days >= self.moderate_after:
312                return True
313        return False
314    
315    def email(self, comment, content_object):
316        """
317        Send email notification of a new comment to site staff when email
318        notifications have been requested.
319        
320        """
321        if not self.email_notification:
322            return
323        recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS]
324        t = loader.get_template('comment_utils/comment_notification_email.txt')
325        c = Context({ 'comment': comment,
326                      'content_object': content_object })
327        subject = '[%s] New comment posted on "%s"' % (Site.objects.get_current().name,
328                                                          content_object)
329        message = t.render(c)
330        send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True)
331
332
333class AkismetModerator(CommentModerator):
334    """
335    Subclass of ``CommentModerator`` which applies Akismet spam
336    filtering to all new comments for its model.
337    
338    """
339    akismet = True
340
341
342class AlwaysModerate(CommentModerator):
343    """
344    Subclass of ``CommentModerator`` which forces all new comments for
345    its model into moderation (marks all comments non-public to begin
346    with).
347    
348    """
349    def moderate(self, comment, content_object):
350        """
351        Always return ``True``, no matter what comment or content
352        object is supplied, so that new comments always get marked
353        non-public to start with.
354        
355        """
356        return True
357
358    def comments_moderated(self, obj):
359        """
360        Always return ``True``, no matter what object is supplied,
361        because new comments always get moderated.
362        
363        """
364        return True
365
366
367class NoComments(CommentModerator):
368    """
369    Subclass of ``CommentModerator`` which forbids all new comments
370    for its model (deletes all comments posted to objects of that
371    model).
372    
373    """
374    def allow(self, comment, content_object):
375        """
376        Always return ``False`` because new comments are never allowed
377        for this model.
378        
379        """
380        return False
381
382    def comments_open(self, obj):
383        """
384        Always return ``False``, because new comments are never
385        allowed for this model.
386        
387        """
388        return False
389    
390
391class ModerateFirstTimers(CommentModerator):
392    """
393    Subclass of ``CommentModerator`` which automatically moderates all
394    comments from anyone who has not previously had a comment
395    approved, while allowing all other comments to skip moderation.
396    
397    """
398    kwarg_builder = { Comment: lambda c: { 'user__username__exact': c.user.username },
399                      FreeComment: lambda c: { 'person_name__exact': c.person_name },
400                      }
401    
402    def moderate(self, comment, content_object):
403        """
404        For each new comment, checks to see if the person submitting
405        it has any previously-approved comments; if not, the comment
406        will be moderated.
407        
408        """
409        comment_class = comment.__class__
410        person_kwargs = self.kwarg_builder[comment_class](comment)
411        approved_comments = comment_class.objects.filter(is_public__exact=True, **person_kwargs)
412        if approved_comments.count() == 0:
413            return True
414        return False
415
416
417class Moderator(object):
418    """
419    Handles moderation of a set of models.
420    
421    An instance of this class will maintain a list of one or more
422    models registered for comment moderation, and their associated
423    moderation classes, and apply moderation to all incoming comments.
424    
425    To register a model, obtain an instance of ``CommentModerator``
426    (this module exports one as ``moderator``), and call its
427    ``register`` method, passing the model class and a moderation
428    class (which should be a subclass of ``CommentModerator``). Note
429    that both of these should be the actual classes, not instances of
430    the classes.
431    
432    To cease moderation for a model, call the ``unregister`` method,
433    passing the model class.
434    
435    For convenience, both ``register`` and ``unregister`` can also
436    accept a list of model classes in place of a single model; this
437    allows easier registration of multiple models with the same
438    ``CommentModerator`` class.
439    
440    The actual moderation is applied in two phases: one prior to
441    saving a new comment, and the other immediately after saving. The
442    pre-save moderation may mark a comment as non-public or mark it to
443    be removed; the post-save moderation may delete a comment which
444    was disallowed (there is currently no way to prevent the comment
445    being saved once before removal) and, if the comment is still
446    around, will send any notification emails the comment generated.
447    
448    """
449    def __init__(self):
450        self._registry = {}
451        self.connect()
452    
453    def connect(self):
454        """
455        Hook up the moderation methods to pre- and post-save signals
456        from the comment models.
457        
458        """
459        for model in (Comment, FreeComment):
460            signals.pre_save.connect(self.pre_save_moderation, sender=model)
461            signals.pre_save.connect(self.post_save_moderation, sender=model)
462    
463    def register(self, model_or_iterable, moderation_class):
464        """
465        Register a model or a list of models for comment moderation,
466        using a particular moderation class.
467        
468        Raise ``AlreadyModerated`` if any of the models are already
469        registered.
470        
471        """
472        if isinstance(model_or_iterable, ModelBase):
473            model_or_iterable = [model_or_iterable]
474        for model in model_or_iterable:
475            if model in self._registry:
476                raise AlreadyModerated("The model '%s' is already being moderated" % model._meta.module_name)
477            self._registry[model] = moderation_class(model)
478    
479    def unregister(self, model_or_iterable):
480        """
481        Remove a model or a list of models from the list of models
482        whose comments will be moderated.
483        
484        Raise ``NotModerated`` if any of the models are not currently
485        registered for moderation.
486        
487        """
488        if isinstance(model_or_iterable, ModelBase):
489            model_or_iterable = [model_or_iterable]
490        for model in model_or_iterable:
491            if model not in self._registry:
492                raise NotModerated("The model '%s' is not currently being moderated" % model._meta.module_name)
493            del self._registry[model]
494    
495    def pre_save_moderation(self, sender, instance, **kwargs):
496        """
497        Apply any necessary pre-save moderation steps to new
498        comments.
499        
500        """
501        model = instance.content_type.model_class()
502        if instance.id or (model not in self._registry):
503            return
504        content_object = instance.get_content_object()
505        moderation_class = self._registry[model]
506        if not moderation_class.allow(instance, content_object): # Comment will get deleted in post-save hook.
507            instance.moderation_disallowed = True
508            return
509        if moderation_class.moderate(instance, content_object):
510            instance.is_public = False
511    
512    def post_save_moderation(self, sender, instance, **kwargs):
513        """
514        Apply any necessary post-save moderation steps to new
515        comments.
516        
517        """
518        model = instance.content_type.model_class()
519        if model not in self._registry:
520            return
521        if hasattr(instance, 'moderation_disallowed'):
522            instance.delete()
523            return
524        self._registry[model].email(instance, instance.get_content_object())
525
526    def comments_open(self, obj):
527        """
528        Return ``True`` if new comments are being accepted for
529        ``obj``, ``False`` otherwise.
530        
531        If no moderation rules have been registered for the model of
532        which ``obj`` is an instance, comments are assumed to be open
533        for that object.
534        
535        """
536        model = obj.__class__
537        if model not in self._registry:
538            return True
539        return self._registry[model].comments_open(obj)
540
541    def comments_moderated(self, obj):
542        """
543        Return ``True`` if new comments for ``obj`` are being
544        automatically sent to moderation, ``False`` otherwise.
545        
546        If no moderation rules have been registered for the model of
547        which ``obj`` is an instance, comments for that object are
548        assumed not to be moderated.
549        
550        """
551        model = obj.__class__
552        if model not in self._registry:
553            return False
554        return self._registry[model].comments_moderated(obj)
555
556
557# Import this instance in your own code to use in registering
558# your models for moderation.
559moderator = Moderator()