/comment_utils/moderation.py

http://django-comment-utils.googlecode.com/ · Python · 559 lines · 431 code · 26 blank · 102 comment · 32 complexity · 27d90ac4f9987cecde58e343499de781 MD5 · raw file

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