Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
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
@vdboor vdboor authored
View
15 fluent_comments/appsettings.py
@@ -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'")
View
203 fluent_comments/moderation.py
@@ -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)
View
5 fluent_comments/static/fluent_comments/css/ajaxcomments.css
@@ -11,3 +11,8 @@
#comment-thanks {
padding-left: 10px;
}
+
+.comment-moderated-flag {
+ font-variant: small-caps;
+ margin-left: 5px;
+}
View
13 fluent_comments/static/fluent_comments/js/ajaxcomments.js
@@ -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);
View
1  fluent_comments/templates/comments/comment.html
@@ -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 %}
View
19 fluent_comments/templates/comments/comment_notification_email.txt
@@ -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 %}
View
21 fluent_comments/templates/comments/deleted.html
@@ -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 %}
View
21 fluent_comments/templates/comments/flagged.html
@@ -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 %}
View
23 fluent_comments/templates/comments/form.html
@@ -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 %}
View
1  fluent_comments/templates/fluent_comments/templatetags/ajax_comment_tags.html
@@ -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>
View
5 fluent_comments/templatetags/fluent_comments_tags.py
@@ -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)
View
1  fluent_comments/views.py
@@ -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")
View
1  setup.py
@@ -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.