/comment_utils/moderation.py
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()