moderation.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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 django.contrib.comments.moderation import CommentModerator, moderator
  23. class EntryModerator(CommentModerator):
  24. email_notification = True
  25. enable_field = 'enable_comments'
  26. And finally register it for moderation::
  27. moderator.register(Entry, EntryModerator)
  28. This sample class would apply two moderation steps to each new
  29. comment submitted on an Entry:
  30. * If the entry's ``enable_comments`` field is set to ``False``, the
  31. comment will be rejected (immediately deleted).
  32. * If the comment is successfully posted, an email notification of the
  33. comment will be sent to site staff.
  34. For a full list of built-in moderation options and other
  35. configurability, see the documentation for the ``CommentModerator``
  36. class.
  37. """
  38. import datetime
  39. from django.conf import settings
  40. from django.core.mail import send_mail
  41. from django.contrib.comments import signals
  42. from django.db.models.base import ModelBase
  43. from django.template import Context, loader
  44. from django.contrib import comments
  45. from django.contrib.sites.shortcuts import get_current_site
  46. from django.utils import timezone
  47. class AlreadyModerated(Exception):
  48. """
  49. Raised when a model which is already registered for moderation is
  50. attempting to be registered again.
  51. """
  52. pass
  53. class NotModerated(Exception):
  54. """
  55. Raised when a model which is not registered for moderation is
  56. attempting to be unregistered.
  57. """
  58. pass
  59. class CommentModerator(object):
  60. """
  61. Encapsulates comment-moderation options for a given model.
  62. This class is not designed to be used directly, since it doesn't
  63. enable any of the available moderation options. Instead, subclass
  64. it and override attributes to enable different options::
  65. ``auto_close_field``
  66. If this is set to the name of a ``DateField`` or
  67. ``DateTimeField`` on the model for which comments are
  68. being moderated, new comments for objects of that model
  69. will be disallowed (immediately deleted) when a certain
  70. number of days have passed after the date specified in
  71. that field. Must be used in conjunction with
  72. ``close_after``, which specifies the number of days past
  73. which comments should be disallowed. Default value is
  74. ``None``.
  75. ``auto_moderate_field``
  76. Like ``auto_close_field``, but instead of outright
  77. deleting new comments when the requisite number of days
  78. have elapsed, it will simply set the ``is_public`` field
  79. of new comments to ``False`` before saving them. Must be
  80. used in conjunction with ``moderate_after``, which
  81. specifies the number of days past which comments should be
  82. moderated. Default value is ``None``.
  83. ``close_after``
  84. If ``auto_close_field`` is used, this must specify the
  85. number of days past the value of the field specified by
  86. ``auto_close_field`` after which new comments for an
  87. object should be disallowed. Default value is ``None``.
  88. ``email_notification``
  89. If ``True``, any new comment on an object of this model
  90. which survives moderation will generate an email to site
  91. staff. Default value is ``False``.
  92. ``enable_field``
  93. If this is set to the name of a ``BooleanField`` on the
  94. model for which comments are being moderated, new comments
  95. on objects of that model will be disallowed (immediately
  96. deleted) whenever the value of that field is ``False`` on
  97. the object the comment would be attached to. Default value
  98. is ``None``.
  99. ``moderate_after``
  100. If ``auto_moderate_field`` is used, this must specify the number
  101. of days past the value of the field specified by
  102. ``auto_moderate_field`` after which new comments for an
  103. object should be marked non-public. Default value is
  104. ``None``.
  105. Most common moderation needs can be covered by changing these
  106. attributes, but further customization can be obtained by
  107. subclassing and overriding the following methods. Each method will
  108. be called with three arguments: ``comment``, which is the comment
  109. being submitted, ``content_object``, which is the object the
  110. comment will be attached to, and ``request``, which is the
  111. ``HttpRequest`` in which the comment is being submitted::
  112. ``allow``
  113. Should return ``True`` if the comment should be allowed to
  114. post on the content object, and ``False`` otherwise (in
  115. which case the comment will be immediately deleted).
  116. ``email``
  117. If email notification of the new comment should be sent to
  118. site staff or moderators, this method is responsible for
  119. sending the email.
  120. ``moderate``
  121. Should return ``True`` if the comment should be moderated
  122. (in which case its ``is_public`` field will be set to
  123. ``False`` before saving), and ``False`` otherwise (in
  124. which case the ``is_public`` field will not be changed).
  125. Subclasses which want to introspect the model for which comments
  126. are being moderated can do so through the attribute ``_model``,
  127. which will be the model class.
  128. """
  129. auto_close_field = None
  130. auto_moderate_field = None
  131. close_after = None
  132. email_notification = False
  133. enable_field = None
  134. moderate_after = None
  135. def __init__(self, model):
  136. self._model = model
  137. def _get_delta(self, now, then):
  138. """
  139. Internal helper which will return a ``datetime.timedelta``
  140. representing the time between ``now`` and ``then``. Assumes
  141. ``now`` is a ``datetime.date`` or ``datetime.datetime`` later
  142. than ``then``.
  143. If ``now`` and ``then`` are not of the same type due to one of
  144. them being a ``datetime.date`` and the other being a
  145. ``datetime.datetime``, both will be coerced to
  146. ``datetime.date`` before calculating the delta.
  147. """
  148. if now.__class__ is not then.__class__:
  149. now = datetime.date(now.year, now.month, now.day)
  150. then = datetime.date(then.year, then.month, then.day)
  151. if now < then:
  152. raise ValueError("Cannot determine moderation rules because date field is set to a value in the future")
  153. return now - then
  154. def allow(self, comment, content_object, request):
  155. """
  156. Determine whether a given comment is allowed to be posted on
  157. a given object.
  158. Return ``True`` if the comment should be allowed, ``False
  159. otherwise.
  160. """
  161. if self.enable_field:
  162. if not getattr(content_object, self.enable_field):
  163. return False
  164. if self.auto_close_field and self.close_after is not None:
  165. close_after_date = getattr(content_object, self.auto_close_field)
  166. if close_after_date is not None and self._get_delta(timezone.now(), close_after_date).days >= self.close_after:
  167. return False
  168. return True
  169. def moderate(self, comment, content_object, request):
  170. """
  171. Determine whether a given comment on a given object should be
  172. allowed to show up immediately, or should be marked non-public
  173. and await approval.
  174. Return ``True`` if the comment should be moderated (marked
  175. non-public), ``False`` otherwise.
  176. """
  177. if self.auto_moderate_field and self.moderate_after is not None:
  178. moderate_after_date = getattr(content_object, self.auto_moderate_field)
  179. if moderate_after_date is not None and self._get_delta(timezone.now(), moderate_after_date).days >= self.moderate_after:
  180. return True
  181. return False
  182. def email(self, comment, content_object, request):
  183. """
  184. Send email notification of a new comment to site staff when email
  185. notifications have been requested.
  186. """
  187. if not self.email_notification:
  188. return
  189. recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS]
  190. t = loader.get_template('comments/comment_notification_email.txt')
  191. c = Context({'comment': comment,
  192. 'content_object': content_object})
  193. subject = '[%s] New comment posted on "%s"' % (get_current_site(request).name,
  194. content_object)
  195. message = t.render(c)
  196. send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True)
  197. class Moderator(object):
  198. """
  199. Handles moderation of a set of models.
  200. An instance of this class will maintain a list of one or more
  201. models registered for comment moderation, and their associated
  202. moderation classes, and apply moderation to all incoming comments.
  203. To register a model, obtain an instance of ``Moderator`` (this
  204. module exports one as ``moderator``), and call its ``register``
  205. method, passing the model class and a moderation class (which
  206. should be a subclass of ``CommentModerator``). Note that both of
  207. these should be the actual classes, not instances of the classes.
  208. To cease moderation for a model, call the ``unregister`` method,
  209. passing the model class.
  210. For convenience, both ``register`` and ``unregister`` can also
  211. accept a list of model classes in place of a single model; this
  212. allows easier registration of multiple models with the same
  213. ``CommentModerator`` class.
  214. The actual moderation is applied in two phases: one prior to
  215. saving a new comment, and the other immediately after saving. The
  216. pre-save moderation may mark a comment as non-public or mark it to
  217. be removed; the post-save moderation may delete a comment which
  218. was disallowed (there is currently no way to prevent the comment
  219. being saved once before removal) and, if the comment is still
  220. around, will send any notification emails the comment generated.
  221. """
  222. def __init__(self):
  223. self._registry = {}
  224. self.connect()
  225. def connect(self):
  226. """
  227. Hook up the moderation methods to pre- and post-save signals
  228. from the comment models.
  229. """
  230. signals.comment_will_be_posted.connect(self.pre_save_moderation, sender=comments.get_model())
  231. signals.comment_was_posted.connect(self.post_save_moderation, sender=comments.get_model())
  232. def register(self, model_or_iterable, moderation_class):
  233. """
  234. Register a model or a list of models for comment moderation,
  235. using a particular moderation class.
  236. Raise ``AlreadyModerated`` if any of the models are already
  237. registered.
  238. """
  239. if isinstance(model_or_iterable, ModelBase):
  240. model_or_iterable = [model_or_iterable]
  241. for model in model_or_iterable:
  242. if model in self._registry:
  243. raise AlreadyModerated("The model '%s' is already being moderated" % model._meta.model_name)
  244. self._registry[model] = moderation_class(model)
  245. def unregister(self, model_or_iterable):
  246. """
  247. Remove a model or a list of models from the list of models
  248. whose comments will be moderated.
  249. Raise ``NotModerated`` if any of the models are not currently
  250. registered for moderation.
  251. """
  252. if isinstance(model_or_iterable, ModelBase):
  253. model_or_iterable = [model_or_iterable]
  254. for model in model_or_iterable:
  255. if model not in self._registry:
  256. raise NotModerated("The model '%s' is not currently being moderated" % model._meta.model_name)
  257. del self._registry[model]
  258. def pre_save_moderation(self, sender, comment, request, **kwargs):
  259. """
  260. Apply any necessary pre-save moderation steps to new
  261. comments.
  262. """
  263. model = comment.content_type.model_class()
  264. if model not in self._registry:
  265. return
  266. content_object = comment.content_object
  267. moderation_class = self._registry[model]
  268. # Comment will be disallowed outright (HTTP 403 response)
  269. if not moderation_class.allow(comment, content_object, request):
  270. return False
  271. if moderation_class.moderate(comment, content_object, request):
  272. comment.is_public = False
  273. def post_save_moderation(self, sender, comment, request, **kwargs):
  274. """
  275. Apply any necessary post-save moderation steps to new
  276. comments.
  277. """
  278. model = comment.content_type.model_class()
  279. if model not in self._registry:
  280. return
  281. self._registry[model].email(comment, comment.content_object, request)
  282. # Import this instance in your own code to use in registering
  283. # your models for moderation.
  284. moderator = Moderator()