Permalink
Browse files

Refactored Django's comment system.

Much of this work was done by Thejaswi Puthraya as part of Google's Summer of Code project; much thanks to him for the work, and to them for the program.

This is a backwards-incompatible change; see the upgrading guide in docs/ref/contrib/comments/upgrade.txt for instructions if you were using the old comments system.


git-svn-id: http://code.djangoproject.com/svn/django/trunk@8557 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
1 parent b46e736 commit cba91997a24f3cb154c7c51029c6dd91471f8800 @jacobian jacobian committed Aug 25, 2008
Showing with 2,409 additions and 1,147 deletions.
  1. +1 −0 AUTHORS
  2. +70 −0 django/contrib/comments/__init__.py
  3. +18 −24 django/contrib/comments/admin.py
  4. +13 −20 django/contrib/comments/feeds.py
  5. +159 −0 django/contrib/comments/forms.py
  6. +22 −0 django/contrib/comments/managers.py
  7. +155 −256 django/contrib/comments/models.py
  8. +21 −0 django/contrib/comments/signals.py
  9. +53 −0 django/contrib/comments/templates/comments/400-debug.html
  10. +14 −0 django/contrib/comments/templates/comments/approve.html
  11. +7 −0 django/contrib/comments/templates/comments/approved.html
  12. +10 −0 django/contrib/comments/templates/comments/base.html
  13. +14 −0 django/contrib/comments/templates/comments/delete.html
  14. +7 −0 django/contrib/comments/templates/comments/deleted.html
  15. +14 −0 django/contrib/comments/templates/comments/flag.html
  16. +7 −0 django/contrib/comments/templates/comments/flagged.html
  17. +19 −38 django/contrib/comments/templates/comments/form.html
  18. +0 −13 django/contrib/comments/templates/comments/freeform.html
  19. +75 −0 django/contrib/comments/templates/comments/moderation_queue.html
  20. +7 −0 django/contrib/comments/templates/comments/posted.html
  21. +34 −0 django/contrib/comments/templates/comments/preview.html
  22. +19 −0 django/contrib/comments/templates/comments/reply.html
  23. +34 −0 django/contrib/comments/templates/comments/reply_preview.html
  24. +201 −282 django/contrib/comments/templatetags/comments.py
  25. +0 −13 django/contrib/comments/tests.py
  26. +15 −0 django/contrib/comments/urls.py
  27. +0 −12 django/contrib/comments/urls/comments.py
  28. +99 −376 django/contrib/comments/views/comments.py
  29. +0 −32 django/contrib/comments/views/karma.py
  30. +186 −0 django/contrib/comments/views/moderation.py
  31. +0 −62 django/contrib/comments/views/userflags.py
  32. +58 −0 django/contrib/comments/views/utils.py
  33. +1 −1 docs/_static/djangodocs.css
  34. +35 −17 docs/index.txt
  35. +212 −0 docs/ref/contrib/comments/index.txt
  36. +34 −0 docs/ref/contrib/comments/settings.txt
  37. +63 −0 docs/ref/contrib/comments/upgrade.txt
  38. +4 −1 docs/ref/contrib/index.txt
  39. +2 −0 docs/topics/templates.txt
  40. 0 {django/contrib/comments/urls → tests/regressiontests/comment_tests}/__init__.py
  41. +43 −0 tests/regressiontests/comment_tests/fixtures/comment_tests.json
  42. +22 −0 tests/regressiontests/comment_tests/models.py
  43. +90 −0 tests/regressiontests/comment_tests/tests/__init__.py
  44. +30 −0 tests/regressiontests/comment_tests/tests/app_api_tests.py
  45. +81 −0 tests/regressiontests/comment_tests/tests/comment_form_tests.py
  46. +166 −0 tests/regressiontests/comment_tests/tests/comment_view_tests.py
  47. +48 −0 tests/regressiontests/comment_tests/tests/model_tests.py
  48. +181 −0 tests/regressiontests/comment_tests/tests/moderation_view_tests.py
  49. +65 −0 tests/regressiontests/comment_tests/tests/templatetag_tests.py
View
@@ -322,6 +322,7 @@ answer newbie questions, and generally made Django that much better:
polpak@yahoo.com
Matthias Pronk <django@masida.nl>
Jyrki Pulliainen <jyrki.pulliainen@gmail.com>
+ Thejaswi Puthraya <thejaswi.puthraya@gmail.com>
Johann Queuniet <johann.queuniet@adh.naellia.eu>
Jan Rademaker
Michael Radziej <mir@noris.de>
@@ -0,0 +1,70 @@
+from django.conf import settings
+from django.core import urlresolvers
+from django.core.exceptions import ImproperlyConfigured
+
+# Attributes required in the top-level app for COMMENTS_APP
+REQUIRED_COMMENTS_APP_ATTRIBUTES = ["get_model", "get_form", "get_form_target"]
+
+def get_comment_app():
+ """
+ Get the comment app (i.e. "django.contrib.comments") as defined in the settings
+ """
+ # Make sure the app's in INSTALLED_APPS
+ comments_app = getattr(settings, 'COMMENTS_APP', 'django.contrib.comments')
+ if comments_app not in settings.INSTALLED_APPS:
+ raise ImproperlyConfigured("The COMMENTS_APP (%r) "\
+ "must be in INSTALLED_APPS" % settings.COMMENTS_APP)
+
+ # Try to import the package
+ try:
+ package = __import__(settings.COMMENTS_APP, '', '', [''])
+ except ImportError:
+ raise ImproperlyConfigured("The COMMENTS_APP setting refers to "\
+ "a non-existing package.")
+
+ # Make sure some specific attributes exist inside that package.
+ for attribute in REQUIRED_COMMENTS_APP_ATTRIBUTES:
+ if not hasattr(package, attribute):
+ raise ImproperlyConfigured("The COMMENTS_APP package %r does not "\
+ "define the (required) %r function" % \
+ (package, attribute))
+
+ return package
+
+def get_model():
+ from django.contrib.comments.models import Comment
+ return Comment
+
+def get_form():
+ from django.contrib.comments.forms import CommentForm
+ return CommentForm
+
+def get_form_target():
+ return urlresolvers.reverse("django.contrib.comments.views.comments.post_comment")
+
+def get_flag_url(comment):
+ """
+ Get the URL for the "flag this comment" view.
+ """
+ if settings.COMMENTS_APP != __name__ and hasattr(get_comment_app(), "get_flag_url"):
+ return get_comment_app().get_flag_url(comment)
+ else:
+ return urlresolvers.reverse("django.contrib.comments.views.moderation.flag", args=(comment.id,))
+
+def get_delete_url(comment):
+ """
+ Get the URL for the "delete this comment" view.
+ """
+ if settings.COMMENTS_APP != __name__ and hasattr(get_comment_app(), "get_delete_url"):
+ return get_comment_app().get_flag_url(get_delete_url)
+ else:
+ return urlresolvers.reverse("django.contrib.comments.views.moderation.delete", args=(comment.id,))
+
+def get_approve_url(comment):
+ """
+ Get the URL for the "approve this comment from moderation" view.
+ """
+ if settings.COMMENTS_APP != __name__ and hasattr(get_comment_app(), "get_approve_url"):
+ return get_comment_app().get_approve_url(comment)
+ else:
+ return urlresolvers.reverse("django.contrib.comments.views.moderation.approve", args=(comment.id,))
@@ -1,30 +1,24 @@
from django.contrib import admin
-from django.contrib.comments.models import Comment, FreeComment
+from django.conf import settings
+from django.contrib.comments.models import Comment
+from django.utils.translation import ugettext_lazy as _
-
-class CommentAdmin(admin.ModelAdmin):
+class CommentsAdmin(admin.ModelAdmin):
fieldsets = (
- (None, {'fields': ('content_type', 'object_id', 'site')}),
- ('Content', {'fields': ('user', 'headline', 'comment')}),
- ('Ratings', {'fields': ('rating1', 'rating2', 'rating3', 'rating4', 'rating5', 'rating6', 'rating7', 'rating8', 'valid_rating')}),
- ('Meta', {'fields': ('is_public', 'is_removed', 'ip_address')}),
- )
- list_display = ('user', 'submit_date', 'content_type', 'get_content_object')
- list_filter = ('submit_date',)
- date_hierarchy = 'submit_date'
- search_fields = ('comment', 'user__username')
- raw_id_fields = ('user',)
+ (None,
+ {'fields': ('content_type', 'object_pk', 'site')}
+ ),
+ (_('Content'),
+ {'fields': ('user', 'user_name', 'user_email', 'user_url', 'comment')}
+ ),
+ (_('Metadata'),
+ {'fields': ('submit_date', 'ip_address', 'is_public', 'is_removed')}
+ ),
+ )
-class FreeCommentAdmin(admin.ModelAdmin):
- fieldsets = (
- (None, {'fields': ('content_type', 'object_id', 'site')}),
- ('Content', {'fields': ('person_name', 'comment')}),
- ('Meta', {'fields': ('is_public', 'ip_address', 'approved')}),
- )
- list_display = ('person_name', 'submit_date', 'content_type', 'get_content_object')
- list_filter = ('submit_date',)
+ list_display = ('name', 'content_type', 'object_pk', 'ip_address', 'is_public', 'is_removed')
+ list_filter = ('submit_date', 'site', 'is_public', 'is_removed')
date_hierarchy = 'submit_date'
- search_fields = ('comment', 'person_name')
+ search_fields = ('comment', 'user__username', 'user_name', 'user_email', 'user_url', 'ip_address')
-admin.site.register(Comment, CommentAdmin)
-admin.site.register(FreeComment, FreeCommentAdmin)
+admin.site.register(Comment, CommentsAdmin)
@@ -1,12 +1,10 @@
from django.conf import settings
-from django.contrib.comments.models import Comment, FreeComment
from django.contrib.syndication.feeds import Feed
from django.contrib.sites.models import Site
+from django.contrib import comments
-class LatestFreeCommentsFeed(Feed):
- """Feed of latest free comments on the current site."""
-
- comments_class = FreeComment
+class LatestCommentFeed(Feed):
+ """Feed of latest comments on the current site."""
def title(self):
if not hasattr(self, '_site'):
@@ -23,22 +21,17 @@ def description(self):
self._site = Site.objects.get_current()
return u"Latest comments on %s" % self._site.name
- def get_query_set(self):
- return self.comments_class.objects.filter(site__pk=settings.SITE_ID, is_public=True)
-
def items(self):
- return self.get_query_set()[:40]
-
-class LatestCommentsFeed(LatestFreeCommentsFeed):
- """Feed of latest comments on the current site."""
-
- comments_class = Comment
-
- def get_query_set(self):
- qs = super(LatestCommentsFeed, self).get_query_set()
- qs = qs.filter(is_removed=False)
- if settings.COMMENTS_BANNED_USERS_GROUP:
+ qs = comments.get_model().objects.filter(
+ site__pk = settings.SITE_ID,
+ is_public = True,
+ is_removed = False,
+ )
+ if getattr(settings, 'COMMENTS_BANNED_USERS_GROUP', None):
where = ['user_id NOT IN (SELECT user_id FROM auth_users_group WHERE group_id = %s)']
params = [settings.COMMENTS_BANNED_USERS_GROUP]
qs = qs.extra(where=where, params=params)
- return qs
+ return qs[:40]
+
+ def item_pubdate(self, item):
+ return item.submit_date
@@ -0,0 +1,159 @@
+import re
+import time
+import datetime
+from sha import sha
+from django import forms
+from django.forms.util import ErrorDict
+from django.conf import settings
+from django.http import Http404
+from django.contrib.contenttypes.models import ContentType
+from models import Comment
+from django.utils.text import get_text_list
+from django.utils.translation import ngettext
+from django.utils.translation import ugettext_lazy as _
+
+COMMENT_MAX_LENGTH = getattr(settings,'COMMENT_MAX_LENGTH', 3000)
+
+class CommentForm(forms.Form):
+ name = forms.CharField(label=_("Name"), max_length=50)
+ email = forms.EmailField(label=_("Email address"))
+ url = forms.URLField(label=_("URL"), required=False)
+ comment = forms.CharField(label=_('Comment'), widget=forms.Textarea,
+ max_length=COMMENT_MAX_LENGTH)
+ honeypot = forms.CharField(required=False,
+ label=_('If you enter anything in this field '\
+ 'your comment will be treated as spam'))
+ content_type = forms.CharField(widget=forms.HiddenInput)
+ object_pk = forms.CharField(widget=forms.HiddenInput)
+ timestamp = forms.IntegerField(widget=forms.HiddenInput)
+ security_hash = forms.CharField(min_length=40, max_length=40, widget=forms.HiddenInput)
+
+ def __init__(self, target_object, data=None, initial=None):
+ self.target_object = target_object
+ if initial is None:
+ initial = {}
+ initial.update(self.generate_security_data())
+ super(CommentForm, self).__init__(data=data, initial=initial)
+
+ def get_comment_object(self):
+ """
+ Return a new (unsaved) comment object based on the information in this
+ form. Assumes that the form is already validated and will throw a
+ ValueError if not.
+
+ Does not set any of the fields that would come from a Request object
+ (i.e. ``user`` or ``ip_address``).
+ """
+ if not self.is_valid():
+ raise ValueError("get_comment_object may only be called on valid forms")
+
+ new = Comment(
+ content_type = ContentType.objects.get_for_model(self.target_object),
+ object_pk = str(self.target_object._get_pk_val()),
+ user_name = self.cleaned_data["name"],
+ user_email = self.cleaned_data["email"],
+ user_url = self.cleaned_data["url"],
+ comment = self.cleaned_data["comment"],
+ submit_date = datetime.datetime.now(),
+ site_id = settings.SITE_ID,
+ is_public = True,
+ is_removed = False,
+ )
+
+ # Check that this comment isn't duplicate. (Sometimes people post comments
+ # twice by mistake.) If it is, fail silently by returning the old comment.
+ possible_duplicates = Comment.objects.filter(
+ content_type = new.content_type,
+ object_pk = new.object_pk,
+ user_name = new.user_name,
+ user_email = new.user_email,
+ user_url = new.user_url,
+ )
+ for old in possible_duplicates:
+ if old.submit_date.date() == new.submit_date.date() and old.comment == new.comment:
+ return old
+
+ return new
+
+ def security_errors(self):
+ """Return just those errors associated with security"""
+ errors = ErrorDict()
+ for f in ["honeypot", "timestamp", "security_hash"]:
+ if f in self.errors:
+ errors[f] = self.errors[f]
+ return errors
+
+ def clean_honeypot(self):
+ """Check that nothing's been entered into the honeypot."""
+ value = self.cleaned_data["honeypot"]
+ if value:
+ raise forms.ValidationError(self.fields["honeypot"].label)
+ return value
+
+ def clean_security_hash(self):
+ """Check the security hash."""
+ security_hash_dict = {
+ 'content_type' : self.data.get("content_type", ""),
+ 'object_pk' : self.data.get("object_pk", ""),
+ 'timestamp' : self.data.get("timestamp", ""),
+ }
+ expected_hash = self.generate_security_hash(**security_hash_dict)
+ actual_hash = self.cleaned_data["security_hash"]
+ if expected_hash != actual_hash:
+ raise forms.ValidationError("Security hash check failed.")
+ return actual_hash
+
+ def clean_timestamp(self):
+ """Make sure the timestamp isn't too far (> 2 hours) in the past."""
+ ts = self.cleaned_data["timestamp"]
+ if time.time() - ts > (2 * 60 * 60):
+ raise forms.ValidationError("Timestamp check failed")
+ return ts
+
+ def clean_comment(self):
+ """
+ If COMMENTS_ALLOW_PROFANITIES is False, check that the comment doesn't
+ contain anything in PROFANITIES_LIST.
+ """
+ comment = self.cleaned_data["comment"]
+ if settings.COMMENTS_ALLOW_PROFANITIES == False:
+ # Logic adapted from django.core.validators; it's not clear if they
+ # should be used in newforms or will be deprecated along with the
+ # rest of oldforms
+ bad_words = [w for w in settings.PROFANITIES_LIST if w in comment.lower()]
+ if bad_words:
+ plural = len(bad_words) > 1
+ raise forms.ValidationError(ngettext(
+ "Watch your mouth! The word %s is not allowed here.",
+ "Watch your mouth! The words %s are not allowed here.", plural) % \
+ get_text_list(['"%s%s%s"' % (i[0], '-'*(len(i)-2), i[-1]) for i in bad_words], 'and'))
+ return comment
+
+ def generate_security_data(self):
+ """Generate a dict of security data for "initial" data."""
+ timestamp = int(time.time())
+ security_dict = {
+ 'content_type' : str(self.target_object._meta),
+ 'object_pk' : str(self.target_object._get_pk_val()),
+ 'timestamp' : str(timestamp),
+ 'security_hash' : self.initial_security_hash(timestamp),
+ }
+ return security_dict
+
+ def initial_security_hash(self, timestamp):
+ """
+ Generate the initial security hash from self.content_object
+ and a (unix) timestamp.
+ """
+
+ initial_security_dict = {
+ 'content_type' : str(self.target_object._meta),
+ 'object_pk' : str(self.target_object._get_pk_val()),
+ 'timestamp' : str(timestamp),
+ }
+ return self.generate_security_hash(**initial_security_dict)
+
+ def generate_security_hash(self, content_type, object_pk, timestamp):
+ """Generate a (SHA1) security hash from the provided info."""
+ info = (content_type, object_pk, timestamp, settings.SECRET_KEY)
+ return sha("".join(info)).hexdigest()
@@ -0,0 +1,22 @@
+from django.db import models
+from django.dispatch import dispatcher
+from django.contrib.contenttypes.models import ContentType
+
+class CommentManager(models.Manager):
+
+ def in_moderation(self):
+ """
+ QuerySet for all comments currently in the moderation queue.
+ """
+ return self.get_query_set().filter(is_public=False, is_removed=False)
+
+ def for_model(self, model):
+ """
+ QuerySet for all comments for a particular model (either an instance or
+ a class).
+ """
+ ct = ContentType.objects.get_for_model(model)
+ qs = self.get_query_set().filter(content_type=ct)
+ if isinstance(model, models.Model):
+ qs = qs.filter(object_pk=model._get_pk_val())
+ return qs
Oops, something went wrong.

0 comments on commit cba9199

Please sign in to comment.