| 1 | """ |
| 2 | Copyright (c) 2007, James Bennett |
| 3 | All rights reserved. |
| 4 | |
| 5 | Redistribution and use in source and binary forms, with or without |
| 6 | modification, are permitted provided that the following conditions are |
| 7 | met: |
| 8 | |
| 9 | * Redistributions of source code must retain the above copyright |
| 10 | notice, this list of conditions and the following disclaimer. |
| 11 | * Redistributions in binary form must reproduce the above |
| 12 | copyright notice, this list of conditions and the following |
| 13 | disclaimer in the documentation and/or other materials provided |
| 14 | with the distribution. |
| 15 | * Neither the name of the author nor the names of other |
| 16 | contributors may be used to endorse or promote products derived |
| 17 | from this software without specific prior written permission. |
| 18 | |
| 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| 22 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| 23 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| 26 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| 27 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 30 | """ |
| 31 | |
| 32 | """ |
| 33 | A generic comment-moderation system which allows configuration of |
| 34 | moderation options on a per-model basis. |
| 35 | |
| 36 | To use, do two things: |
| 37 | |
| 38 | 1. Create or import a subclass of ``CommentModerator`` defining the |
| 39 | options you want. |
| 40 | |
| 41 | 2. Import ``moderator`` from this module and register one or more |
| 42 | models, passing the models and the ``CommentModerator`` options |
| 43 | class you want to use. |
| 44 | |
| 45 | |
| 46 | Example |
| 47 | ------- |
| 48 | |
| 49 | First, we define a simple model class which might represent entries in |
| 50 | a weblog:: |
| 51 | |
| 52 | from django.db import models |
| 53 | |
| 54 | class Entry(models.Model): |
| 55 | title = models.CharField(maxlength=250) |
| 56 | body = models.TextField() |
| 57 | pub_date = models.DateField() |
| 58 | enable_comments = models.BooleanField() |
| 59 | |
| 60 | Then we create a ``CommentModerator`` subclass specifying some |
| 61 | moderation options:: |
| 62 | |
| 63 | from django.contrib.comments.moderators import CommentModerator, moderator |
| 64 | |
| 65 | class EntryModerator(CommentModerator): |
| 66 | email_notification = True |
| 67 | enable_field = 'enable_comments' |
| 68 | |
| 69 | And finally register it for moderation:: |
| 70 | |
| 71 | moderator.register(Entry, EntryModerator) |
| 72 | |
| 73 | This sample class would apply several moderation steps to each new |
| 74 | comment submitted on an Entry: |
| 75 | |
| 76 | * If the entry's ``enable_comments`` field is set to ``False``, the |
| 77 | comment will be rejected (immediately deleted). |
| 78 | |
| 79 | * If the comment is successfully posted, an email notification of the |
| 80 | comment will be sent to site staff. |
| 81 | |
| 82 | For a full list of built-in moderation options and other |
| 83 | configurability, see the documentation for the ``CommentModerator`` |
| 84 | class. |
| 85 | |
| 86 | Several example subclasses of ``CommentModerator`` are provided in |
| 87 | `django-comment-utils`_, both to provide common moderation options and to |
| 88 | demonstrate some of the ways subclasses can customize moderation |
| 89 | behavior. |
| 90 | |
| 91 | .. _`django-comment-utils`: http://code.google.com/p/django-comment-utils/ |
| 92 | """ |
| 93 | |
| 94 | |
| 95 | import datetime |
| 96 | |
| 97 | from django.conf import settings |
| 98 | from django.core.mail import send_mail |
| 99 | from django.db.models import signals |
| 100 | from django.db.models.base import ModelBase |
| 101 | from django.template import Context, loader |
| 102 | from django.contrib import comments |
| 103 | from django.contrib.sites.models import Site |
| 104 | |
| 105 | |
| 106 | class AlreadyModerated(Exception): |
| 107 | """ |
| 108 | Raised when a model which is already registered for moderation is |
| 109 | attempting to be registered again. |
| 110 | |
| 111 | """ |
| 112 | pass |
| 113 | |
| 114 | |
| 115 | class NotModerated(Exception): |
| 116 | """ |
| 117 | Raised when a model which is not registered for moderation is |
| 118 | attempting to be unregistered. |
| 119 | |
| 120 | """ |
| 121 | pass |
| 122 | |
| 123 | |
| 124 | class CommentModerator(object): |
| 125 | """ |
| 126 | Encapsulates comment-moderation options for a given model. |
| 127 | |
| 128 | This class is not designed to be used directly, since it doesn't |
| 129 | enable any of the available moderation options. Instead, subclass |
| 130 | it and override attributes to enable different options:: |
| 131 | |
| 132 | ``auto_close_field`` |
| 133 | If this is set to the name of a ``DateField`` or |
| 134 | ``DateTimeField`` on the model for which comments are |
| 135 | being moderated, new comments for objects of that model |
| 136 | will be disallowed (immediately deleted) when a certain |
| 137 | number of days have passed after the date specified in |
| 138 | that field. Must be used in conjunction with |
| 139 | ``close_after``, which specifies the number of days past |
| 140 | which comments should be disallowed. Default value is |
| 141 | ``None``. |
| 142 | |
| 143 | ``auto_moderate_field`` |
| 144 | Like ``auto_close_field``, but instead of outright |
| 145 | deleting new comments when the requisite number of days |
| 146 | have elapsed, it will simply set the ``is_public`` field |
| 147 | of new comments to ``False`` before saving them. Must be |
| 148 | used in conjunction with ``moderate_after``, which |
| 149 | specifies the number of days past which comments should be |
| 150 | moderated. Default value is ``None``. |
| 151 | |
| 152 | ``close_after`` |
| 153 | If ``auto_close_field`` is used, this must specify the |
| 154 | number of days past the value of the field specified by |
| 155 | ``auto_close_field`` after which new comments for an |
| 156 | object should be disallowed. Default value is ``None``. |
| 157 | |
| 158 | ``email_notification`` |
| 159 | If ``True``, any new comment on an object of this model |
| 160 | which survives moderation will generate an email to site |
| 161 | staff. Default value is ``False``. |
| 162 | |
| 163 | ``enable_field`` |
| 164 | If this is set to the name of a ``BooleanField`` on the |
| 165 | model for which comments are being moderated, new comments |
| 166 | on objects of that model will be disallowed (immediately |
| 167 | deleted) whenever the value of that field is ``False`` on |
| 168 | the object the comment would be attached to. Default value |
| 169 | is ``None``. |
| 170 | |
| 171 | ``moderate_after`` |
| 172 | If ``auto_moderate`` is used, this must specify the number |
| 173 | of days past the value of the field specified by |
| 174 | ``auto_moderate_field`` after which new comments for an |
| 175 | object should be marked non-public. Default value is |
| 176 | ``None``. |
| 177 | |
| 178 | Most common moderation needs can be covered by changing these |
| 179 | attributes, but further customization can be obtained by |
| 180 | subclassing and overriding the following methods. Each method will |
| 181 | be called with two arguments: ``comment``, which is the comment |
| 182 | being submitted, and ``content_object``, which is the object the |
| 183 | comment will be attached to:: |
| 184 | |
| 185 | ``allow`` |
| 186 | Should return ``True`` if the comment should be allowed to |
| 187 | post on the content object, and ``False`` otherwise (in |
| 188 | which case the comment will be immediately deleted). |
| 189 | |
| 190 | ``email`` |
| 191 | If email notification of the new comment should be sent to |
| 192 | site staff or moderators, this method is responsible for |
| 193 | sending the email. |
| 194 | |
| 195 | ``moderate`` |
| 196 | Should return ``True`` if the comment should be moderated |
| 197 | (in which case its ``is_public`` field will be set to |
| 198 | ``False`` before saving), and ``False`` otherwise (in |
| 199 | which case the ``is_public`` field will not be changed). |
| 200 | |
| 201 | Subclasses which want to introspect the model for which comments |
| 202 | are being moderated can do so through the attribute ``_model``, |
| 203 | which will be the model class. |
| 204 | |
| 205 | """ |
| 206 | auto_close_field = None |
| 207 | auto_moderate_field = None |
| 208 | close_after = None |
| 209 | email_notification = False |
| 210 | enable_field = None |
| 211 | moderate_after = None |
| 212 | |
| 213 | def __init__(self, model): |
| 214 | self._model = model |
| 215 | |
| 216 | def _get_delta(self, now, then): |
| 217 | """ |
| 218 | Internal helper which will return a ``datetime.timedelta`` |
| 219 | representing the time between ``now`` and ``then``. Assumes |
| 220 | ``now`` is a ``datetime.date`` or ``datetime.datetime`` later |
| 221 | than ``then``. |
| 222 | |
| 223 | If ``now`` and ``then`` are not of the same type due to one of |
| 224 | them being a ``datetime.date`` and the other being a |
| 225 | ``datetime.datetime``, both will be coerced to |
| 226 | ``datetime.date`` before calculating the delta. |
| 227 | |
| 228 | """ |
| 229 | if now.__class__ is not then.__class__: |
| 230 | now = datetime.date(now.year, now.month, now.day) |
| 231 | then = datetime.date(then.year, then.month, then.day) |
| 232 | if now < then: |
| 233 | raise ValueError("Cannot determine moderation rules because date field is set to a value in the future") |
| 234 | return now - then |
| 235 | |
| 236 | def allow(self, comment, content_object): |
| 237 | """ |
| 238 | Determine whether a given comment is allowed to be posted on |
| 239 | a given object. |
| 240 | |
| 241 | Return ``True`` if the comment should be allowed, ``False |
| 242 | otherwise. |
| 243 | |
| 244 | """ |
| 245 | if self.enable_field: |
| 246 | if not getattr(content_object, self.enable_field): |
| 247 | return False |
| 248 | if self.auto_close_field and self.close_after: |
| 249 | if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_close_field)).days >= self.close_after: |
| 250 | return False |
| 251 | return True |
| 252 | |
| 253 | def moderate(self, comment, content_object): |
| 254 | """ |
| 255 | Determine whether a given comment on a given object should be |
| 256 | allowed to show up immediately, or should be marked non-public |
| 257 | and await approval. |
| 258 | |
| 259 | Return ``True`` if the comment should be moderated (marked |
| 260 | non-public), ``False`` otherwise. |
| 261 | |
| 262 | """ |
| 263 | if self.auto_moderate_field and self.moderate_after: |
| 264 | if self._get_delta(datetime.datetime.now(), getattr(content_object, self.auto_moderate_field)).days >= self.moderate_after: |
| 265 | return True |
| 266 | return False |
| 267 | |
| 268 | def comments_open(self, obj): |
| 269 | """ |
| 270 | Return ``True`` if new comments are being accepted for |
| 271 | ``obj``, ``False`` otherwise. |
| 272 | |
| 273 | The algorithm for determining this is as follows: |
| 274 | |
| 275 | 1. If ``enable_field`` is set and the relevant field on |
| 276 | ``obj`` contains a false value, comments are not open. |
| 277 | |
| 278 | 2. If ``close_after`` is set and the relevant date field on |
| 279 | ``obj`` is far enough in the past, comments are not open. |
| 280 | |
| 281 | 3. If neither of the above checks determined that comments are |
| 282 | not open, comments are open. |
| 283 | |
| 284 | """ |
| 285 | if self.enable_field: |
| 286 | if not getattr(obj, self.enable_field): |
| 287 | return False |
| 288 | if self.auto_close_field and self.close_after: |
| 289 | if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_close_field)).days >= self.close_after: |
| 290 | return False |
| 291 | return True |
| 292 | |
| 293 | def comments_moderated(self, obj): |
| 294 | """ |
| 295 | Return ``True`` if new comments for ``obj`` are being |
| 296 | automatically sent to moderation, ``False`` otherwise. |
| 297 | |
| 298 | The algorithm for determining this is as follows: |
| 299 | |
| 300 | 1. If ``moderate_field`` is set and the relevant field on |
| 301 | ``obj`` contains a true value, comments are moderated. |
| 302 | |
| 303 | 2. If ``moderate_after`` is set and the relevant date field on |
| 304 | ``obj`` is far enough in the past, comments are moderated. |
| 305 | |
| 306 | 3. If neither of the above checks decided that comments are |
| 307 | moderated, comments are not moderated. |
| 308 | |
| 309 | """ |
| 310 | if self.moderate_field: |
| 311 | if getattr(obj, self.moderate_field): |
| 312 | return True |
| 313 | if self.auto_moderate_field and self.moderate_after: |
| 314 | if self._get_delta(datetime.datetime.now(), getattr(obj, self.auto_moderate_field)).days >= self.moderate_after: |
| 315 | return True |
| 316 | return False |
| 317 | |
| 318 | def email(self, comment, content_object): |
| 319 | """ |
| 320 | Send email notification of a new comment to site staff when email |
| 321 | notifications have been requested. |
| 322 | |
| 323 | """ |
| 324 | if not self.email_notification: |
| 325 | return |
| 326 | recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS] |
| 327 | t = loader.get_template('comments/comment_notification_email.txt') |
| 328 | c = Context({ 'comment': comment, |
| 329 | 'content_object': content_object }) |
| 330 | subject = '[%s] New comment posted on "%s"' % (Site.objects.get_current().name, |
| 331 | content_object) |
| 332 | message = t.render(c) |
| 333 | send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True) |
| 334 | |
| 335 | |
| 336 | class Moderator(object): |
| 337 | """ |
| 338 | Handles moderation of a set of models. |
| 339 | |
| 340 | An instance of this class will maintain a list of one or more |
| 341 | models registered for comment moderation, and their associated |
| 342 | moderation classes, and apply moderation to all incoming comments. |
| 343 | |
| 344 | To register a model, obtain an instance of ``CommentModerator`` |
| 345 | (this module exports one as ``moderator``), and call its |
| 346 | ``register`` method, passing the model class and a moderation |
| 347 | class (which should be a subclass of ``CommentModerator``). Note |
| 348 | that both of these should be the actual classes, not instances of |
| 349 | the classes. |
| 350 | |
| 351 | To cease moderation for a model, call the ``unregister`` method, |
| 352 | passing the model class. |
| 353 | |
| 354 | For convenience, both ``register`` and ``unregister`` can also |
| 355 | accept a list of model classes in place of a single model; this |
| 356 | allows easier registration of multiple models with the same |
| 357 | ``CommentModerator`` class. |
| 358 | |
| 359 | The actual moderation is applied in two phases: one prior to |
| 360 | saving a new comment, and the other immediately after saving. The |
| 361 | pre-save moderation may mark a comment as non-public or mark it to |
| 362 | be removed; the post-save moderation may delete a comment which |
| 363 | was disallowed (there is currently no way to prevent the comment |
| 364 | being saved once before removal) and, if the comment is still |
| 365 | around, will send any notification emails the comment generated. |
| 366 | |
| 367 | """ |
| 368 | def __init__(self): |
| 369 | self._registry = {} |
| 370 | self.connect() |
| 371 | |
| 372 | def connect(self): |
| 373 | """ |
| 374 | Hook up the moderation methods to pre- and post-save signals |
| 375 | from the comment models. |
| 376 | |
| 377 | """ |
| 378 | signals.pre_save.connect(self.pre_save_moderation, sender=comments.get_model()) |
| 379 | signals.post_save.connect(self.post_save_moderation, sender=comments.get_model()) |
| 380 | |
| 381 | def register(self, model_or_iterable, moderation_class): |
| 382 | """ |
| 383 | Register a model or a list of models for comment moderation, |
| 384 | using a particular moderation class. |
| 385 | |
| 386 | Raise ``AlreadyModerated`` if any of the models are already |
| 387 | registered. |
| 388 | |
| 389 | """ |
| 390 | if isinstance(model_or_iterable, ModelBase): |
| 391 | model_or_iterable = [model_or_iterable] |
| 392 | for model in model_or_iterable: |
| 393 | if model in self._registry: |
| 394 | raise AlreadyModerated("The model '%s' is already being moderated" % model._meta.module_name) |
| 395 | self._registry[model] = moderation_class(model) |
| 396 | |
| 397 | def unregister(self, model_or_iterable): |
| 398 | """ |
| 399 | Remove a model or a list of models from the list of models |
| 400 | whose comments will be moderated. |
| 401 | |
| 402 | Raise ``NotModerated`` if any of the models are not currently |
| 403 | registered for moderation. |
| 404 | |
| 405 | """ |
| 406 | if isinstance(model_or_iterable, ModelBase): |
| 407 | model_or_iterable = [model_or_iterable] |
| 408 | for model in model_or_iterable: |
| 409 | if model not in self._registry: |
| 410 | raise NotModerated("The model '%s' is not currently being moderated" % model._meta.module_name) |
| 411 | del self._registry[model] |
| 412 | |
| 413 | def pre_save_moderation(self, sender, instance, **kwargs): |
| 414 | """ |
| 415 | Apply any necessary pre-save moderation steps to new |
| 416 | comments. |
| 417 | |
| 418 | """ |
| 419 | model = instance.content_type.model_class() |
| 420 | if instance.id or (model not in self._registry): |
| 421 | return |
| 422 | content_object = instance.content_object |
| 423 | moderation_class = self._registry[model] |
| 424 | if not moderation_class.allow(instance, content_object): # Comment will get deleted in post-save hook. |
| 425 | instance.moderation_disallowed = True |
| 426 | return |
| 427 | if moderation_class.moderate(instance, content_object): |
| 428 | instance.is_public = False |
| 429 | |
| 430 | def post_save_moderation(self, sender, instance, **kwargs): |
| 431 | """ |
| 432 | Apply any necessary post-save moderation steps to new |
| 433 | comments. |
| 434 | |
| 435 | """ |
| 436 | model = instance.content_type.model_class() |
| 437 | if model not in self._registry: |
| 438 | return |
| 439 | if hasattr(instance, 'moderation_disallowed'): |
| 440 | instance.delete() |
| 441 | return |
| 442 | self._registry[model].email(instance, instance.content_object) |
| 443 | |
| 444 | def comments_open(self, obj): |
| 445 | """ |
| 446 | Return ``True`` if new comments are being accepted for |
| 447 | ``obj``, ``False`` otherwise. |
| 448 | |
| 449 | If no moderation rules have been registered for the model of |
| 450 | which ``obj`` is an instance, comments are assumed to be open |
| 451 | for that object. |
| 452 | |
| 453 | """ |
| 454 | model = obj.__class__ |
| 455 | if model not in self._registry: |
| 456 | return True |
| 457 | return self._registry[model].comments_open(obj) |
| 458 | |
| 459 | def comments_moderated(self, obj): |
| 460 | """ |
| 461 | Return ``True`` if new comments for ``obj`` are being |
| 462 | automatically sent to moderation, ``False`` otherwise. |
| 463 | |
| 464 | If no moderation rules have been registered for the model of |
| 465 | which ``obj`` is an instance, comments for that object are |
| 466 | assumed not to be moderated. |
| 467 | |
| 468 | """ |
| 469 | model = obj.__class__ |
| 470 | if model not in self._registry: |
| 471 | return False |
| 472 | return self._registry[model].comments_moderated(obj) |
| 473 | |
| 474 | |
| 475 | # Import this instance in your own code to use in registering |
| 476 | # your models for moderation. |
| 477 | moderator = Moderator() |