Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add comment moderation support

Using standard Django policies, configurable via the settings file.
and included Akismet filtering from django-comments-spamfighter
  • Loading branch information...
commit f82b27fd9bb95d491bb8fea8c90bd44dfac8b9b9 1 parent e7a7830
Diederik van der Boor vdboor authored
15 fluent_comments/appsettings.py
View
@@ -1,7 +1,20 @@
from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
-FLUENT_COMMENTS_REPLACE_ADMIN = getattr(settings, "FLUENT_COMMENTS_REPLACE_ADMIN", True)
+AKISMET_API_KEY = getattr(settings, 'AKISMET_API_KEY', None)
+AKISMET_BLOG_URL = getattr(settings, 'AKISMET_BLOG_URL', None) # Optional, to override auto detection
CRISPY_TEMPLATE_PACK = getattr(settings, 'CRISPY_TEMPLATE_PACK', 'bootstrap')
USE_THREADEDCOMMENTS = 'threadedcomments' in settings.INSTALLED_APPS
+
+FLUENT_COMMENTS_REPLACE_ADMIN = getattr(settings, "FLUENT_COMMENTS_REPLACE_ADMIN", True)
+
+FLUENT_CONTENTS_USE_AKISMET = getattr(settings, 'FLUENT_CONTENTS_USE_AKISMET', bool(AKISMET_API_KEY))
+FLUENT_COMMENTS_USE_EMAIL_MODERATION = getattr(settings, 'FLUENT_COMMENTS_USE_EMAIL_MODERATION', True) # enable by default
+FLUENT_COMMENTS_CLOSE_AFTER_DAYS = getattr(settings, 'FLUENT_COMMENTS_CLOSE_AFTER_DAYS', None)
+FLUENT_COMMENTS_MODERATE_AFTER_DAYS = getattr(settings, 'FLUENT_COMMENTS_MODERATE_AFTER_DAYS', None)
+FLUENT_COMMENTS_AKISMET_ACTION = getattr(settings, 'FLUENT_COMMENTS_AKISMET_ACTION', 'moderate') # or 'delete'
+
+if FLUENT_COMMENTS_AKISMET_ACTION not in ('moderate', 'delete'):
+ raise ImproperlyConfigured("FLUENT_COMMENTS_AKISMET_ACTION can be 'moderate' or 'delete'")
203 fluent_comments/moderation.py
View
@@ -0,0 +1,203 @@
+from django.conf import settings
+from django.contrib.comments.moderation import CommentModerator, moderator
+from django.contrib.sites.models import get_current_site
+from django.core.exceptions import ImproperlyConfigured
+from django.core.mail import send_mail
+from django.http import HttpRequest
+from django.shortcuts import render
+from django.utils.encoding import smart_str
+from akismet import Akismet
+from fluent_comments import appsettings
+
+# Akismet code originally based on django-comments-spamfighter.
+
+__all__ = (
+ 'FluentCommentsModerator',
+ 'moderate_model',
+ 'get_model_moderator',
+ 'comments_are_closed',
+ 'comments_are_moderated',
+)
+
+
+class FluentCommentsModerator(CommentModerator):
+ """
+ Moderation policy for fluent-comments.
+ """
+ auto_close_field = None
+ auto_moderate_field = None
+ enable_field = None
+
+ close_after = appsettings.FLUENT_COMMENTS_CLOSE_AFTER_DAYS
+ moderate_after = appsettings.FLUENT_COMMENTS_MODERATE_AFTER_DAYS
+ email_notification = appsettings.FLUENT_COMMENTS_USE_EMAIL_MODERATION
+ akismet_check = appsettings.FLUENT_CONTENTS_USE_AKISMET
+ akismet_check_action = appsettings.FLUENT_COMMENTS_AKISMET_ACTION
+
+
+ def allow(self, comment, content_object, request):
+ """
+ Determine whether a given comment is allowed to be posted on a given object.
+
+ Returns ``True`` if the comment should be allowed, ``False`` otherwise.
+ """
+ # Parent class check
+ if not super(FluentCommentsModerator, self).allow(comment, content_object, request):
+ return False
+
+ # Akismet check
+ if self.akismet_check and self.akismet_check_action == 'delete':
+ if self._akismet_check(comment, content_object, request):
+ return False # Akismet marked the comment as spam.
+
+ return True
+
+
+ def moderate(self, comment, content_object, request):
+ """
+ Determine whether a given comment on a given object should be allowed to show up immediately,
+ or should be marked non-public and await approval.
+
+ Returns ``True`` if the comment should be moderated (marked non-public), ``False`` otherwise.
+ """
+ # Parent class check
+ if super(FluentCommentsModerator, self).moderate(comment, content_object, request):
+ return True
+
+ # Akismet check
+ if self.akismet_check and self.akismet_check_action == 'moderate':
+ # Return True if akismet marks this comment as spam and we want to moderate it.
+ if self._akismet_check(comment, content_object, request):
+ return True
+
+ return False
+
+
+ def email(self, comment, content_object, request):
+ """
+ Send email notification of a new comment to site staff when email notifications have been requested.
+ """
+ # This code is copied from django.contrib.comments.moderation,
+ # since it doesn't offer a RequestContext, making it really hard to generate URL's with FQDN in the email
+ if not self.email_notification:
+ return
+
+ recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS]
+ site = get_current_site(request)
+ subject = '[{0}] New comment posted on "{1}"'.format(site.name, content_object)
+ context = {
+ 'site': site,
+ 'comment': comment,
+ 'content_object': content_object
+ }
+ message = render(request, "comments/comment_notification_email.txt", context)
+ send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True)
+
+
+ def _akismet_check(self, comment, content_object, request):
+ """
+ Connects to Akismet and returns True if Akismet marks this comment as
+ spam. Otherwise returns False.
+ """
+ # Get Akismet data
+ AKISMET_API_KEY = appsettings.AKISMET_API_KEY
+ if not AKISMET_API_KEY:
+ raise ImproperlyConfigured('You must set AKISMET_API_KEY to use comment moderation with Akismet.')
+
+ auto_blog_url = '{0}://{1}/'.format(request.is_secure() and 'https' or 'http', get_current_site(request).domain)
+ akismet_api = Akismet(
+ key=AKISMET_API_KEY,
+ blog_url=appsettings.AKISMET_BLOG_URL or auto_blog_url
+ )
+
+ if akismet_api.verify_key():
+ akismet_data = {
+ # Comment info
+ 'permalink': content_object.get_absolute_url(),
+ 'comment_type': 'comment',
+ 'comment_author': getattr(comment, 'name', ''),
+ 'comment_author_email': getattr(comment, 'email', ''),
+ 'comment_author_url': getattr(comment, 'url', ''),
+
+ # Request info
+ 'referrer': request.META.get('HTTP_REFERER', ''),
+ 'user_agent': request.META.get('HTTP_USER_AGENT', ''),
+ 'user_ip': comment.ip_address,
+
+ # Server info
+ 'SERVER_ADDR': request.META.get('SERVER_ADDR', ''),
+ 'SERVER_ADMIN': request.META.get('SERVER_ADMIN', ''),
+ 'SERVER_NAME': request.META.get('SERVER_NAME', ''),
+ 'SERVER_PORT': request.META.get('SERVER_PORT', ''),
+ 'SERVER_SIGNATURE': request.META.get('SERVER_SIGNATURE', ''),
+ 'SERVER_SOFTWARE': request.META.get('SERVER_SOFTWARE', ''),
+ 'HTTP_ACCEPT': request.META.get('HTTP_ACCEPT', ''),
+ }
+
+ if akismet_api.comment_check(smart_str(comment.comment), data=akismet_data, build_data=True):
+ return True
+
+ return False
+
+
+def moderate_model(ParentModel, publication_date_field=None, enable_comments_field=None):
+ """
+ Register a parent model (e.g. ``Blog`` or ``Article``) that should receive comment moderation.
+
+ :param ParentModel: The parent model, e.g. a ``Blog`` or ``Article`` model.
+ :param publication_date_field: The field name of a :class:`~django.db.models.DateTimeField` in the parent model which stores the publication date.
+ :type publication_date_field: str
+ :param enable_comments_field: The field name of a :class:`~django.db.models.BooleanField` in the parent model which stores the whether comments are enabled.
+ :type enable_comments_field: str
+ """
+ attrs = {
+ 'auto_close_field': publication_date_field,
+ 'auto_moderate_field': publication_date_field,
+ 'enable_field': enable_comments_field,
+ }
+ ModerationClass = type(ParentModel.__name__ + 'Moderator', (FluentCommentsModerator,), attrs)
+ moderator.register(ParentModel, ModerationClass)
+
+
+def get_model_moderator(model):
+ """
+ Return the moderator class that is registered with a content object.
+ If there is no associated moderator with a class, None is returned.
+
+ :param model: The Django model registered with :func:`moderate_model`
+ :type model: :class:`~django.db.models.Model`
+ :return: The moderator class which holds the moderation policies.
+ :rtype: :class:`~django.contrib.comments.moderation.CommentModerator`
+ """
+ try:
+ return moderator._registry[model]
+ except KeyError:
+ return None
+
+
+def comments_are_closed(content_object):
+ """
+ Return whether comments are closed for a given target object.
+ """
+ moderator = get_model_moderator(content_object.__class__)
+ if moderator is None:
+ return False
+
+ # Check the 'enable_field', 'auto_close_field' and 'close_after',
+ # by reusing the basic Django policies.
+ request = HttpRequest()
+ return not CommentModerator.allow(moderator, None, content_object, request)
+
+
+def comments_are_moderated(content_object):
+ """
+ Return whether comments are moderated for a given target object.
+ """
+ moderator = get_model_moderator(content_object.__class__)
+ if moderator is None:
+ return False
+
+ # Check the 'auto_moderate_field', 'moderate_after',
+ # by reusing the basic Django policies.
+ request = HttpRequest()
+ return CommentModerator.moderate(moderator, None, content_object, request)
5 fluent_comments/static/fluent_comments/css/ajaxcomments.css
View
@@ -11,3 +11,8 @@
#comment-thanks {
padding-left: 10px;
}
+
+.comment-moderated-flag {
+ font-variant: small-caps;
+ margin-left: 5px;
+}
13 fluent_comments/static/fluent_comments/js/ajaxcomments.js
View
@@ -90,11 +90,16 @@
}
- function onCommentPosted( comment_id, $comment )
+ function onCommentPosted( comment_id, is_moderated, $comment )
{
- $("#comment-added-message").fadeIn(200);
+ var $message_span;
+ if( is_moderated )
+ $message_span = $("#comment-moderated-message").fadeIn(200);
+ else
+ $message_span = $("#comment-added-message").fadeIn(200);
+
setTimeout(function(){ scrollToComment(comment_id, 1000); }, 1000);
- setTimeout(function(){ $("#comment-added-message").fadeOut(500) }, 4000);
+ setTimeout(function(){ $message_span.fadeOut(500) }, 4000);
}
@@ -146,7 +151,7 @@
$added = commentSuccess(data);
if( onsuccess )
- args.onsuccess(data.comment_id, $added);
+ args.onsuccess(data.comment_id, data.is_moderated, $added);
}
else {
commentFailure(data);
1  fluent_comments/templates/comments/comment.html
View
@@ -25,6 +25,7 @@
{% if comment.name %}{{ comment.name }}{% else %}{% trans "Anonymous" %}{% endif %}{% comment %}
{% endcomment %}{% if comment.url %}</a>{% endif %}<span>&nbsp;</span>
<span class="comment-date">{% blocktrans with submit_date=comment.submit_date %}on {{ submit_date }}{% endblocktrans %}</span>
+ {% if not comment.is_public %}<span class="comment-moderated-flag">({% trans "moderated" %})</span>{% endif %}
</h4>
{% endspaceless %}
19 fluent_comments/templates/comments/comment_notification_email.txt
View
@@ -0,0 +1,19 @@
+{% load url from future %}{% autoescape off %}{% comment %}
+{% endcomment %}A new comment has been posted on your site "{{ site }}, to the page entitled "{{ content_object }}".
+Link to the page: http://{{ site.domain }}{{ content_object.get_absolute_url }}
+
+IP-address: 95.97.240.121{% if comment.title %}
+Title: {{ comment.title }}{% endif %}
+Name: {{ comment.user|default:comment.user_name }}
+Email: {{ comment.user_email }}
+Homepage: {{ comment.user_url }}
+
+Comments:
+{{ comment.comment }}
+
+----
+You have the following options available:
+ View comment -- http://{{ site.domain }}{{ comment.get_absolute_url }}
+ Flag comment -- http://{{ site.domain }}{% url 'comments-flag' comment.pk %}
+ Delete comment -- http://{{ site.domain }}{% url 'comments-delete' comment.pk %}
+{% endautoescape %}
21 fluent_comments/templates/comments/deleted.html
View
@@ -0,0 +1,21 @@
+{% extends "comments/base.html" %}
+{% load i18n %}
+
+{% block title %}{% trans "Thanks for removing" %}.{% endblock %}
+
+{% block extrahead %}
+ {{ block.super }}
+ <meta http-equiv="Refresh" content="5; url={{ comment.content_object.get_absolute_url }}#c{{ comment.id }}" />
+{% endblock %}
+
+{% block content %}
+ <h2>{% trans "Thanks for removing the comment" %}</h2>
+ <p>
+ {% blocktrans %}
+ Thanks for taking the time to improve the quality of discussion on our site.<br/>
+ You will be sent back to the article...
+ {% endblocktrans %}
+ </p>
+
+ <p><a href="{{ comment.content_object.get_absolute_url }}#c{{ comment.id }}">{% trans "Back to the article" %}</a></p>
+{% endblock %}
21 fluent_comments/templates/comments/flagged.html
View
@@ -0,0 +1,21 @@
+{% extends "comments/base.html" %}
+{% load i18n %}
+
+{% block title %}{% trans "Thanks for flagging" %}.{% endblock %}
+
+{% block extrahead %}
+ {{ block.super }}
+ <meta http-equiv="Refresh" content="5; url={{ comment.content_object.get_absolute_url }}#c{{ comment.id }}" />
+{% endblock %}
+
+{% block content %}
+ <h2>{% trans "Thanks for flagging the comment" %}</h2>
+ <p>
+ {% blocktrans %}
+ Thanks for taking the time to improve the quality of discussion on our site.<br/>
+ You will be sent back to the article...
+ {% endblocktrans %}
+ </p>
+
+ <p><a href="{{ comment.content_object.get_absolute_url }}#c{{ comment.id }}">{% trans "Back to the article" %}</a></p>
+{% endblock %}
23 fluent_comments/templates/comments/form.html
View
@@ -1,13 +1,18 @@
{% load comments i18n crispy_forms_tags fluent_comments_tags %}{% load url from future %}
-<form action="{% comment_form_target %}" data-ajax-action="{% url 'comments-post-comment-ajax' %}" method="post" class="js-comments-form comments-form form-horizontal">{% csrf_token %}
- {% if next %}<div><input type="hidden" name="next" value="{{ next }}" /></div>{% endif %}
+{% if form.target_object|comments_are_closed %}
+ <p>{% trans "Comments are closed." %}</p>
+{% else %}
+ <form action="{% comment_form_target %}" method="post" class="js-comments-form comments-form form-horizontal"
+ data-ajax-action="{% url 'comments-post-comment-ajax' %}">{% csrf_token %}
+ {% if next %}<div><input type="hidden" name="next" value="{{ next }}" /></div>{% endif %}
- {{ form|crispy }}
+ {{ form|crispy }}
- <div class="form-actions">
- <input type="submit" name="post" class="btn btn-submit" value="{% trans "Post" %}" />
- <input type="submit" name="preview" class="btn btn-submit" value="{% trans "Preview" %}" />
- {% ajax_comment_tags %}
- </div>
-</form>
+ <div class="form-actions">
+ <input type="submit" name="post" class="btn btn-submit" value="{% trans "Post" %}" />
+ <input type="submit" name="preview" class="btn btn-submit" value="{% trans "Preview" %}" />
+ {% ajax_comment_tags %}
+ </div>
+ </form>
+{% endif %}
1  fluent_comments/templates/fluent_comments/templatetags/ajax_comment_tags.html
View
@@ -3,3 +3,4 @@
<img src="{{ STATIC_URL }}fluent_comments/img/ajax-wait.gif" alt="" class="ajax-loader" />{% trans "Please wait . . ." %}
</span>
<span id="comment-added-message" style="display: none;">{% trans "Your comment has been posted!" %}</span>
+<div id="comment-moderated-message" style="display: none;">{% trans "Your comment has been posted, it will be visible for other users after approval." %}</div>
5 fluent_comments/templatetags/fluent_comments_tags.py
View
@@ -1,5 +1,6 @@
from django.template import Library
from django.core import context_processors
+from fluent_comments.moderation import comments_are_closed, comments_are_moderated
register = Library()
@@ -13,3 +14,7 @@ def ajax_comment_tags(context):
new_context = {}
new_context.update(context_processors.static(request))
return new_context
+
+
+register.filter('comments_are_closed', comments_are_closed)
+register.filter('comments_are_moderated', comments_are_moderated)
1  fluent_comments/views.py
View
@@ -132,6 +132,7 @@ def _ajax_result(request, form, action, comment=None):
'errors': json_errors,
'html': comment_html,
'comment_id': comment.id if comment else None,
+ 'is_moderated': not comment.is_public, # is_public flags changes in comment_will_be_posted
})
return HttpResponse(json_response, mimetype="application/json")
1  setup.py
View
@@ -21,6 +21,7 @@
install_requires=[
'Django>=1.2.0',
'django-crispy-forms>=1.1.1',
+ 'akismet>=0.2',
],
description='A modern, ajax-based appearance for django.contrib.comments',
long_description=open('README.rst').read(),
Please sign in to comment.
Something went wrong with that request. Please try again.