2
0
Эх сурвалжийг харах

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
Jacob Kaplan-Moss 16 жил өмнө
parent
commit
cba91997a2
49 өөрчлөгдсөн 2409 нэмэгдсэн , 1147 устгасан
  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 0
      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

+ 1 - 0
AUTHORS

@@ -322,6 +322,7 @@ answer newbie questions, and generally made Django that much better:
     polpak@yahoo.com
     polpak@yahoo.com
     Matthias Pronk <django@masida.nl>
     Matthias Pronk <django@masida.nl>
     Jyrki Pulliainen <jyrki.pulliainen@gmail.com>
     Jyrki Pulliainen <jyrki.pulliainen@gmail.com>
+    Thejaswi Puthraya <thejaswi.puthraya@gmail.com>
     Johann Queuniet <johann.queuniet@adh.naellia.eu>
     Johann Queuniet <johann.queuniet@adh.naellia.eu>
     Jan Rademaker
     Jan Rademaker
     Michael Radziej <mir@noris.de>
     Michael Radziej <mir@noris.de>

+ 70 - 0
django/contrib/comments/__init__.py

@@ -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,))

+ 18 - 24
django/contrib/comments/admin.py

@@ -1,30 +1,24 @@
 from django.contrib import admin
 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 = (
     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'
     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)

+ 13 - 20
django/contrib/comments/feeds.py

@@ -1,12 +1,10 @@
 from django.conf import settings
 from django.conf import settings
-from django.contrib.comments.models import Comment, FreeComment
 from django.contrib.syndication.feeds import Feed
 from django.contrib.syndication.feeds import Feed
 from django.contrib.sites.models import Site
 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):
     def title(self):
         if not hasattr(self, '_site'):
         if not hasattr(self, '_site'):
@@ -23,22 +21,17 @@ class LatestFreeCommentsFeed(Feed):
             self._site = Site.objects.get_current()
             self._site = Site.objects.get_current()
         return u"Latest comments on %s" % self._site.name
         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):
     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)']
             where = ['user_id NOT IN (SELECT user_id FROM auth_users_group WHERE group_id = %s)']
             params = [settings.COMMENTS_BANNED_USERS_GROUP]
             params = [settings.COMMENTS_BANNED_USERS_GROUP]
             qs = qs.extra(where=where, params=params)
             qs = qs.extra(where=where, params=params)
-        return qs
+        return qs[:40]
+        
+    def item_pubdate(self, item):
+        return item.submit_date

+ 159 - 0
django/contrib/comments/forms.py

@@ -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()

+ 22 - 0
django/contrib/comments/managers.py

@@ -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

+ 155 - 256
django/contrib/comments/models.py

@@ -1,286 +1,185 @@
 import datetime
 import datetime
-
-from django.db import models
+from django.contrib.auth.models import User
+from django.contrib.comments.managers import CommentManager
+from django.contrib.contenttypes import generic
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.sites.models import Site
 from django.contrib.sites.models import Site
-from django.contrib.auth.models import User
+from django.db import models
+from django.core import urlresolvers, validators
 from django.utils.translation import ugettext_lazy as _
 from django.utils.translation import ugettext_lazy as _
 from django.conf import settings
 from django.conf import settings
 
 
-MIN_PHOTO_DIMENSION = 5
-MAX_PHOTO_DIMENSION = 1000
-
-# Option codes for comment-form hidden fields.
-PHOTOS_REQUIRED = 'pr'
-PHOTOS_OPTIONAL = 'pa'
-RATINGS_REQUIRED = 'rr'
-RATINGS_OPTIONAL = 'ra'
-IS_PUBLIC = 'ip'
-
-# What users get if they don't have any karma.
-DEFAULT_KARMA = 5
-KARMA_NEEDED_BEFORE_DISPLAYED = 3
-
-
-class CommentManager(models.Manager):
-    def get_security_hash(self, options, photo_options, rating_options, target):
-        """
-        Returns the MD5 hash of the given options (a comma-separated string such as
-        'pa,ra') and target (something like 'lcom.eventtimes:5157'). Used to
-        validate that submitted form options have not been tampered-with.
-        """
-        from django.utils.hashcompat import md5_constructor
-        return md5_constructor(options + photo_options + rating_options + target + settings.SECRET_KEY).hexdigest()
-
-    def get_rating_options(self, rating_string):
-        """
-        Given a rating_string, this returns a tuple of (rating_range, options).
-        >>> s = "scale:1-10|First_category|Second_category"
-        >>> Comment.objects.get_rating_options(s)
-        ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], ['First category', 'Second category'])
-        """
-        rating_range, options = rating_string.split('|', 1)
-        rating_range = range(int(rating_range[6:].split('-')[0]), int(rating_range[6:].split('-')[1])+1)
-        choices = [c.replace('_', ' ') for c in options.split('|')]
-        return rating_range, choices
-
-    def get_list_with_karma(self, **kwargs):
-        """
-        Returns a list of Comment objects matching the given lookup terms, with
-        _karma_total_good and _karma_total_bad filled.
-        """
-        extra_kwargs = {}
-        extra_kwargs.setdefault('select', {})
-        extra_kwargs['select']['_karma_total_good'] = 'SELECT COUNT(*) FROM comments_karmascore, comments_comment WHERE comments_karmascore.comment_id=comments_comment.id AND score=1'
-        extra_kwargs['select']['_karma_total_bad'] = 'SELECT COUNT(*) FROM comments_karmascore, comments_comment WHERE comments_karmascore.comment_id=comments_comment.id AND score=-1'
-        return self.filter(**kwargs).extra(**extra_kwargs)
+COMMENT_MAX_LENGTH = getattr(settings,'COMMENT_MAX_LENGTH',3000)
 
 
-    def user_is_moderator(self, user):
-        if user.is_superuser:
-            return True
-        for g in user.groups.all():
-            if g.id == settings.COMMENTS_MODERATORS_GROUP:
-                return True
-        return False
+class BaseCommentAbstractModel(models.Model):
+    """
+    An abstract base class that any custom comment models probably should
+    subclass.
+    """
+    
+    # Content-object field
+    content_type   = models.ForeignKey(ContentType)
+    object_pk      = models.TextField(_('object ID'))
+    content_object = generic.GenericForeignKey(ct_field="content_type", fk_field="object_pk")
 
 
-
-class Comment(models.Model):
-    """A comment by a registered user."""
-    user = models.ForeignKey(User)
-    content_type = models.ForeignKey(ContentType)
-    object_id = models.IntegerField(_('object ID'))
-    headline = models.CharField(_('headline'), max_length=255, blank=True)
-    comment = models.TextField(_('comment'), max_length=3000)
-    rating1 = models.PositiveSmallIntegerField(_('rating #1'), blank=True, null=True)
-    rating2 = models.PositiveSmallIntegerField(_('rating #2'), blank=True, null=True)
-    rating3 = models.PositiveSmallIntegerField(_('rating #3'), blank=True, null=True)
-    rating4 = models.PositiveSmallIntegerField(_('rating #4'), blank=True, null=True)
-    rating5 = models.PositiveSmallIntegerField(_('rating #5'), blank=True, null=True)
-    rating6 = models.PositiveSmallIntegerField(_('rating #6'), blank=True, null=True)
-    rating7 = models.PositiveSmallIntegerField(_('rating #7'), blank=True, null=True)
-    rating8 = models.PositiveSmallIntegerField(_('rating #8'), blank=True, null=True)
-    # This field designates whether to use this row's ratings in aggregate
-    # functions (summaries). We need this because people are allowed to post
-    # multiple reviews on the same thing, but the system will only use the
-    # latest one (with valid_rating=True) in tallying the reviews.
-    valid_rating = models.BooleanField(_('is valid rating'))
-    submit_date = models.DateTimeField(_('date/time submitted'), auto_now_add=True)
-    is_public = models.BooleanField(_('is public'))
-    ip_address = models.IPAddressField(_('IP address'), blank=True, null=True)
-    is_removed = models.BooleanField(_('is removed'), help_text=_('Check this box if the comment is inappropriate. A "This comment has been removed" message will be displayed instead.'))
-    site = models.ForeignKey(Site)
-    objects = CommentManager()
+    # Metadata about the comment
+    site        = models.ForeignKey(Site)
 
 
     class Meta:
     class Meta:
-        verbose_name = _('comment')
-        verbose_name_plural = _('comments')
-        ordering = ('-submit_date',)
-
-    def __unicode__(self):
-        return "%s: %s..." % (self.user.username, self.comment[:100])
+        abstract = True
 
 
-    def get_absolute_url(self):
-        try:
-            return self.get_content_object().get_absolute_url() + "#c" + str(self.id)
-        except AttributeError:
-            return ""
-
-    def get_crossdomain_url(self):
-        return "/r/%d/%d/" % (self.content_type_id, self.object_id)
-
-    def get_flag_url(self):
-        return "/comments/flag/%s/" % self.id
-
-    def get_deletion_url(self):
-        return "/comments/delete/%s/" % self.id
-
-    def get_content_object(self):
+    def get_content_object_url(self):
         """
         """
-        Returns the object that this comment is a comment on. Returns None if
-        the object no longer exists.
+        Get a URL suitable for redirecting to the content object. Uses the
+        ``django.views.defaults.shortcut`` view, which thus must be installed.
         """
         """
-        from django.core.exceptions import ObjectDoesNotExist
-        try:
-            return self.content_type.get_object_for_this_type(pk=self.object_id)
-        except ObjectDoesNotExist:
-            return None
-
-    get_content_object.short_description = _('Content object')
-
-    def _fill_karma_cache(self):
-        """Helper function that populates good/bad karma caches."""
-        good, bad = 0, 0
-        for k in self.karmascore_set:
-            if k.score == -1:
-                bad +=1
-            elif k.score == 1:
-                good +=1
-        self._karma_total_good, self._karma_total_bad = good, bad
-
-    def get_good_karma_total(self):
-        if not hasattr(self, "_karma_total_good"):
-            self._fill_karma_cache()
-        return self._karma_total_good
-
-    def get_bad_karma_total(self):
-        if not hasattr(self, "_karma_total_bad"):
-            self._fill_karma_cache()
-        return self._karma_total_bad
-
-    def get_karma_total(self):
-        if not hasattr(self, "_karma_total_good") or not hasattr(self, "_karma_total_bad"):
-            self._fill_karma_cache()
-        return self._karma_total_good + self._karma_total_bad
-
-    def get_as_text(self):
-        return _('Posted by %(user)s at %(date)s\n\n%(comment)s\n\nhttp://%(domain)s%(url)s') % \
-            {'user': self.user.username, 'date': self.submit_date,
-            'comment': self.comment, 'domain': self.site.domain, 'url': self.get_absolute_url()}
-
-
-class FreeComment(models.Model):
-    """A comment by a non-registered user."""
-    content_type = models.ForeignKey(ContentType)
-    object_id = models.IntegerField(_('object ID'))
-    comment = models.TextField(_('comment'), max_length=3000)
-    person_name = models.CharField(_("person's name"), max_length=50)
-    submit_date = models.DateTimeField(_('date/time submitted'), auto_now_add=True)
-    is_public = models.BooleanField(_('is public'))
-    ip_address = models.IPAddressField(_('ip address'))
-    # TODO: Change this to is_removed, like Comment
-    approved = models.BooleanField(_('approved by staff'))
-    site = models.ForeignKey(Site)
+        return urlresolvers.reverse(
+            "django.views.defaults.shortcut",
+            args=(self.content_type_id, self.object_pk)
+        )
+
+class Comment(BaseCommentAbstractModel):
+    """
+    A user comment about some object.
+    """
+
+    # Who posted this comment? If ``user`` is set then it was an authenticated
+    # user; otherwise at least person_name should have been set and the comment
+    # was posted by a non-authenticated user.
+    user        = models.ForeignKey(User, blank=True, null=True, related_name="%(class)s_comments")
+    user_name   = models.CharField(_("user's name"), max_length=50, blank=True)
+    user_email  = models.EmailField(_("user's email address"), blank=True)
+    user_url    = models.URLField(_("user's URL"), blank=True)
+
+    comment = models.TextField(_('comment'), max_length=COMMENT_MAX_LENGTH)
+
+    # Metadata about the comment
+    submit_date = models.DateTimeField(_('date/time submitted'), default=None)
+    ip_address  = models.IPAddressField(_('IP address'), blank=True, null=True)
+    is_public   = models.BooleanField(_('is public'), default=True,
+                    help_text=_('Uncheck this box to make the comment effectively ' \
+                                'disappear from the site.'))
+    is_removed  = models.BooleanField(_('is removed'), default=False,
+                    help_text=_('Check this box if the comment is inappropriate. ' \
+                                'A "This comment has been removed" message will ' \
+                                'be displayed instead.'))
+
+    # Manager
+    objects = CommentManager()
 
 
     class Meta:
     class Meta:
-        verbose_name = _('free comment')
-        verbose_name_plural = _('free comments')
-        ordering = ('-submit_date',)
+        db_table = "django_comments"
+        ordering = ('submit_date',)
+        permissions = [("can_moderate", "Can moderate comments")]
 
 
     def __unicode__(self):
     def __unicode__(self):
-        return "%s: %s..." % (self.person_name, self.comment[:100])
+        return "%s: %s..." % (self.name, self.comment[:50])
 
 
-    def get_absolute_url(self):
-        try:
-            return self.get_content_object().get_absolute_url() + "#c" + str(self.id)
-        except AttributeError:
-            return ""
+    def save(self):
+        if self.submit_date is None:
+            self.submit_date = datetime.datetime.now()
+        super(Comment, self).save()
 
 
-    def get_content_object(self):
+    def _get_userinfo(self):
         """
         """
-        Returns the object that this comment is a comment on. Returns None if
-        the object no longer exists.
-        """
-        from django.core.exceptions import ObjectDoesNotExist
-        try:
-            return self.content_type.get_object_for_this_type(pk=self.object_id)
-        except ObjectDoesNotExist:
-            return None
-
-    get_content_object.short_description = _('Content object')
-
-
-class KarmaScoreManager(models.Manager):
-    def vote(self, user_id, comment_id, score):
-        try:
-            karma = self.get(comment__pk=comment_id, user__pk=user_id)
-        except self.model.DoesNotExist:
-            karma = self.model(None, user_id=user_id, comment_id=comment_id, score=score, scored_date=datetime.datetime.now())
-            karma.save()
-        else:
-            karma.score = score
-            karma.scored_date = datetime.datetime.now()
-            karma.save()
+        Get a dictionary that pulls together information about the poster
+        safely for both authenticated and non-authenticated comments.
 
 
-    def get_pretty_score(self, score):
+        This dict will have ``name``, ``email``, and ``url`` fields.
         """
         """
-        Given a score between -1 and 1 (inclusive), returns the same score on a
-        scale between 1 and 10 (inclusive), as an integer.
-        """
-        if score is None:
-            return DEFAULT_KARMA
-        return int(round((4.5 * score) + 5.5))
-
-
-class KarmaScore(models.Model):
-    user = models.ForeignKey(User)
-    comment = models.ForeignKey(Comment)
-    score = models.SmallIntegerField(_('score'), db_index=True)
-    scored_date = models.DateTimeField(_('score date'), auto_now=True)
-    objects = KarmaScoreManager()
+        if not hasattr(self, "_userinfo"):
+            self._userinfo = {
+                "name"  : self.user_name,
+                "email" : self.user_email,
+                "url"   : self.user_url
+            }
+            if self.user_id:
+                u = self.user
+                if u.email:
+                    self._userinfo["email"] = u.email
+
+                # If the user has a full name, use that for the user name.
+                # However, a given user_name overrides the raw user.username,
+                # so only use that if this comment has no associated name.
+                if u.get_full_name():
+                    self._userinfo["name"] = self.user.get_full_name()
+                elif not self.user_name:
+                    self._userinfo["name"] = u.username
+        return self._userinfo
+    userinfo = property(_get_userinfo, doc=_get_userinfo.__doc__)
+
+    def _get_name(self):
+        return self.userinfo["name"]
+    def _set_name(self, val):
+        if self.user_id:
+            raise AttributeError(_("This comment was posted by an authenticated "\
+                                   "user and thus the name is read-only."))
+        self.user_name = val
+    name = property(_get_name, _set_name, doc="The name of the user who posted this comment")
+
+    def _get_email(self):
+        return self.userinfo["email"]
+    def _set_email(self, val):
+        if self.user_id:
+            raise AttributeError(_("This comment was posted by an authenticated "\
+                                   "user and thus the email is read-only."))
+        self.user_email = val
+    email = property(_get_email, _set_email, doc="The email of the user who posted this comment")
+
+    def _get_url(self):
+        return self.userinfo["url"]
+    def _set_url(self, val):
+        self.user_url = val
+    url = property(_get_url, _set_url, doc="The URL given by the user who posted this comment")
+
+    def get_absolute_url(self, anchor_pattern="#c%(id)s"):
+        return self.get_content_object_url() + (anchor_pattern % self.__dict__)
 
 
-    class Meta:
-        verbose_name = _('karma score')
-        verbose_name_plural = _('karma scores')
-        unique_together = (('user', 'comment'),)
-
-    def __unicode__(self):
-        return _("%(score)d rating by %(user)s") % {'score': self.score, 'user': self.user}
-
-
-class UserFlagManager(models.Manager):
-    def flag(self, comment, user):
+    def get_as_text(self):
         """
         """
-        Flags the given comment by the given user. If the comment has already
-        been flagged by the user, or it was a comment posted by the user,
-        nothing happens.
+        Return this comment as plain text.  Useful for emails.
         """
         """
-        if int(comment.user_id) == int(user.id):
-            return # A user can't flag his own comment. Fail silently.
-        try:
-            f = self.get(user__pk=user.id, comment__pk=comment.id)
-        except self.model.DoesNotExist:
-            from django.core.mail import mail_managers
-            f = self.model(None, user.id, comment.id, None)
-            message = _('This comment was flagged by %(user)s:\n\n%(text)s') % {'user': user.username, 'text': comment.get_as_text()}
-            mail_managers('Comment flagged', message, fail_silently=True)
-            f.save()
-
-
-class UserFlag(models.Model):
-    user = models.ForeignKey(User)
-    comment = models.ForeignKey(Comment)
-    flag_date = models.DateTimeField(_('flag date'), auto_now_add=True)
-    objects = UserFlagManager()
+        d = {
+            'user': self.user,
+            'date': self.submit_date,
+            'comment': self.comment,
+            'domain': self.site.domain,
+            'url': self.get_absolute_url()
+        }
+        return _('Posted by %(user)s at %(date)s\n\n%(comment)s\n\nhttp://%(domain)s%(url)s') % d
+
+class CommentFlag(models.Model):
+    """
+    Records a flag on a comment. This is intentionally flexible; right now, a
+    flag could be:
+
+        * A "removal suggestion" -- where a user suggests a comment for (potential) removal.
+
+        * A "moderator deletion" -- used when a moderator deletes a comment.
+
+    You can (ab)use this model to add other flags, if needed. However, by
+    design users are only allowed to flag a comment with a given flag once;
+    if you want rating look elsewhere.
+    """
+    user      = models.ForeignKey(User, related_name="comment_flags")
+    comment   = models.ForeignKey(Comment, related_name="flags")
+    flag      = models.CharField(max_length=30, db_index=True)
+    flag_date = models.DateTimeField(default=None)
+
+    # Constants for flag types
+    SUGGEST_REMOVAL = "removal suggestion"
+    MODERATOR_DELETION = "moderator deletion"
+    MODERATOR_APPROVAL = "moderator approval"
 
 
     class Meta:
     class Meta:
-        verbose_name = _('user flag')
-        verbose_name_plural = _('user flags')
-        unique_together = (('user', 'comment'),)
+        db_table = 'django_comment_flags'
+        unique_together = [('user', 'comment', 'flag')]
 
 
     def __unicode__(self):
     def __unicode__(self):
-        return _("Flag by %r") % self.user
-
-
-class ModeratorDeletion(models.Model):
-    user = models.ForeignKey(User, verbose_name='moderator')
-    comment = models.ForeignKey(Comment)
-    deletion_date = models.DateTimeField(_('deletion date'), auto_now_add=True)
+        return "%s flag of comment ID %s by %s" % \
+            (self.flag, self.comment_id, self.user.username)
 
 
-    class Meta:
-        verbose_name = _('moderator deletion')
-        verbose_name_plural = _('moderator deletions')
-        unique_together = (('user', 'comment'),)
-
-    def __unicode__(self):
-        return _("Moderator deletion by %r") % self.user
-        
+    def save(self):
+        if self.flag_date is None:
+            self.flag_date = datetime.datetime.now()
+        super(CommentFlag, self).save()

+ 21 - 0
django/contrib/comments/signals.py

@@ -0,0 +1,21 @@
+"""
+Signals relating to comments.
+"""
+from django.dispatch import Signal
+
+# Sent just before a comment will be posted (after it's been approved and
+# moderated; this can be used to modify the comment (in place) with posting
+# details or other such actions. If any receiver returns False the comment will be
+# discarded and a 403 (not allowed) response. This signal is sent at more or less
+# the same time (just before, actually) as the Comment object's pre-save signal,
+# except that the HTTP request is sent along with this signal.
+comment_will_be_posted = Signal()
+
+# Sent just after a comment was posted. See above for how this differs
+# from the Comment object's post-save signal.
+comment_was_posted = Signal()
+
+# Sent after a comment was "flagged" in some way. Check the flag to see if this
+# was a user requesting removal of a comment, a moderator approving/removing a
+# comment, or some other custom user flag.
+comment_was_flagged = Signal()

+ 53 - 0
django/contrib/comments/templates/comments/400-debug.html

@@ -0,0 +1,53 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<html lang="en">
+<head>
+  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+  <title>Comment post not allowed (400)</title>
+  <meta name="robots" content="NONE,NOARCHIVE" />
+  <style type="text/css">
+    html * { padding:0; margin:0; }
+    body * { padding:10px 20px; }
+    body * * { padding:0; }
+    body { font:small sans-serif; background:#eee; }
+    body>div { border-bottom:1px solid #ddd; }
+    h1 { font-weight:normal; margin-bottom:.4em; }
+    h1 span { font-size:60%; color:#666; font-weight:normal; }
+    table { border:none; border-collapse: collapse; width:100%; }
+    td, th { vertical-align:top; padding:2px 3px; }
+    th { width:12em; text-align:right; color:#666; padding-right:.5em; }
+    #info { background:#f6f6f6; }
+    #info ol { margin: 0.5em 4em; }
+    #info ol li { font-family: monospace; }
+    #summary { background: #ffc; }
+    #explanation { background:#eee; border-bottom: 0px none; }
+  </style>
+</head>
+<body>
+  <div id="summary">
+    <h1>Comment post not allowed <span>(400)</span></h1>
+    <table class="meta">
+      <tr>
+        <th>Why:</th>
+        <td>{{ why }}</td>
+      </tr>
+    </table>
+  </div>
+  <div id="info">
+    <p>
+    The comment you tried to post to this view wasn't saved because something
+    tampered with the security information in the comment form. The message
+    above should explain the problem, or you can check the <a
+    href="http://www.djangoproject.com/documentation/comments/">comment
+    documentation</a> for more help.
+    </p>
+  </div>
+
+  <div id="explanation">
+    <p>
+      You're seeing this error because you have <code>DEBUG = True</code> in
+      your Django settings file. Change that to <code>False</code>, and Django
+      will display a standard 400 error page.
+    </p>
+  </div>
+</body>
+</html>

+ 14 - 0
django/contrib/comments/templates/comments/approve.html

@@ -0,0 +1,14 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Approve a comment{% endblock %}
+
+{% block content %}
+  <h1>Really make this comment public?</h1>
+  <blockquote>{{ comment|escape|linebreaks }}</blockquote>
+  <form action="." method="POST">
+    <input type="hidden" name="next" value="{{ next|escape }}" id="next">
+    <p class="submit">
+      <input type="submit" name="submit" value="Approve"> or <a href="{{ comment.permalink }}">cancel</a>
+    </p>
+  </form>
+{% endblock %}

+ 7 - 0
django/contrib/comments/templates/comments/approved.html

@@ -0,0 +1,7 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Thanks for approving.{% endblock %}
+
+{% block content %}
+  <h1>Thanks for taking the time to improve the quality of discussion on our site.</h1>
+{% endblock %}

+ 10 - 0
django/contrib/comments/templates/comments/base.html

@@ -0,0 +1,10 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">
+<html lang="en">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+  <title>{% block title %}{% endblock %}</title>
+</head>
+<body>
+  {% block content %}{% endblock %}
+</body>
+</html>

+ 14 - 0
django/contrib/comments/templates/comments/delete.html

@@ -0,0 +1,14 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Remove a comment{% endblock %}
+
+{% block content %}
+  <h1>Really remove this comment?</h1>
+  <blockquote>{{ comment|escape|linebreaks }}</blockquote>
+  <form action="." method="POST">
+    <input type="hidden" name="next" value="{{ next|escape }}" id="next">
+    <p class="submit">
+      <input type="submit" name="submit" value="Remove"> or <a href="{{ comment.permalink }}">cancel</a>
+    </p>
+  </form>
+{% endblock %}

+ 7 - 0
django/contrib/comments/templates/comments/deleted.html

@@ -0,0 +1,7 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Thanks for removing.{% endblock %}
+
+{% block content %}
+  <h1>Thanks for taking the time to improve the quality of discussion on our site.</h1>
+{% endblock %}

+ 14 - 0
django/contrib/comments/templates/comments/flag.html

@@ -0,0 +1,14 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Flag this comment{% endblock %}
+
+{% block content %}
+  <h1>Really flag this comment?</h1>
+  <blockquote>{{ comment|escape|linebreaks }}</blockquote>
+  <form action="." method="POST">
+    <input type="hidden" name="next" value="{{ next|escape }}" id="next">
+    <p class="submit">
+      <input type="submit" name="submit" value="Flag"> or <a href="{{ comment.permalink }}">cancel</a>
+    </p>
+  </form>
+{% endblock %}

+ 7 - 0
django/contrib/comments/templates/comments/flagged.html

@@ -0,0 +1,7 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Thanks for flagging.{% endblock %}
+
+{% block content %}
+  <h1>Thanks for taking the time to improve the quality of discussion on our site.</h1>
+{% endblock %}

+ 19 - 38
django/contrib/comments/templates/comments/form.html

@@ -1,38 +1,19 @@
-{% load i18n %}
-{% if display_form %}
-<form {% if photos_optional or photos_required %}enctype="multipart/form-data" {% endif %}action="/comments/post/" method="post">
-
-{% if user.is_authenticated %}
-<p>{% trans "Username:" %} <strong>{{ user.username }}</strong> (<a href="{{ logout_url }}">{% trans "Log out" %}</a>)</p>
-{% else %}
-<p><label for="id_username">{% trans "Username:" %}</label> <input type="text" name="username" id="id_username" /><br />{% trans "Password:" %} <input type="password" name="password" id="id_password" /> (<a href="/accounts/password_reset/">{% trans "Forgotten your password?" %}</a>)</p>
-{% endif %}
-
-{% if ratings_optional or ratings_required %}
-<p>{% trans "Ratings" %} ({% if ratings_required %}{% trans "Required" %}{% else %}{% trans "Optional" %}{% endif %}):</p>
-<table>
-<tr><th>&nbsp;</th>{% for value in rating_range %}<th>{{ value }}</th>{% endfor %}</tr>
-{% for rating in rating_choices %}
-<tr><th>{{ rating }}</th>{% for value in rating_range %}<th><input type="radio" name="rating{{ forloop.parentloop.counter }}" value="{{ value }}" /></th>{% endfor %}</tr>
-{% endfor %}
-</table>
-<input type="hidden" name="rating_options" value="{{ rating_options }}" />
-{% endif %}
-
-{% if photos_optional or photos_required %}
-<p><label for="id_photo">{% trans "Post a photo" %}</label> ({% if photos_required %}{% trans "Required" %}{% else %}{% trans "Optional" %}{% endif %}):
-<input type="file" name="photo" id="id_photo" /></p>
-<input type="hidden" name="photo_options" value="{{ photo_options }}" />
-{% endif %}
-
-<p><label for="id_comment">{% trans "Comment:" %}</label><br />
-<textarea name="comment" id="id_comment" rows="10" cols="60"></textarea></p>
-
-<p>
-<input type="hidden" name="options" value="{{ options }}" />
-<input type="hidden" name="target" value="{{ target }}" />
-<input type="hidden" name="gonzo" value="{{ hash }}" />
-<input type="submit" name="preview" value="{% trans "Preview comment" %}" />
-</p>
-</form>
-{% endif %}
+{% load comments %}
+<form action="{% comment_form_target %}" method="POST">
+  {% for field in form %}
+    {% if field.is_hidden %}
+      {{ field }}
+    {% else %}
+      <p
+        {% if field.errors %} class="error"{% endif %}
+        {% ifequal field.name "honeypot" %} style="display:none;"{% endifequal %}>
+        {% if field.errors %}{{ field.errors }}{% endif %}
+        {{ field.label_tag }} {{ field }}
+      </p>
+    {% endif %}
+  {% endfor %}
+  <p class="submit">
+    <input type="submit" name="submit" class="submit-post" value="Post">
+    <input type="submit" name="submit" class="submit-preview" value="Preview">
+  </p>
+</form>

+ 0 - 13
django/contrib/comments/templates/comments/freeform.html

@@ -1,13 +0,0 @@
-{% load i18n %}
-{% if display_form %}
-<form action="/comments/postfree/" method="post">
-<p><label for="id_person_name">{% trans "Your name:" %}</label> <input type="text" id="id_person_name" name="person_name" /></p>
-<p><label for="id_comment">{% trans "Comment:" %}</label><br /><textarea name="comment" id="id_comment" rows="10" cols="60"></textarea></p>
-<p>
-<input type="hidden" name="options" value="{{ options }}" />
-<input type="hidden" name="target" value="{{ target }}" />
-<input type="hidden" name="gonzo" value="{{ hash }}" />
-<input type="submit" name="preview" value="{% trans "Preview comment" %}" />
-</p>
-</form>
-{% endif %}

+ 75 - 0
django/contrib/comments/templates/comments/moderation_queue.html

@@ -0,0 +1,75 @@
+{% extends "admin/change_list.html" %}
+{% load adminmedia %}
+
+{% block title %}Comment moderation queue{% endblock %}
+
+{% block extrahead %}
+  {{ block.super }}
+  <style type="text/css" media="screen">
+    p#nocomments { font-size: 200%; text-align: center; border: 1px #ccc dashed; padding: 4em; }
+    td.actions { width: 11em; }
+    td.actions form { display: inline; }
+    td.actions form input.submit { width: 5em; padding: 2px 4px; margin-right: 4px;}
+    td.actions form input.approve { background: green; color: white; }
+    td.actions form input.remove { background: red; color: white; }
+  </style>
+{% endblock %}
+
+{% block branding %}
+<h1 id="site-name">Comment moderation queue</h1>
+{% endblock %}
+
+{% block breadcrumbs %}{% endblock %}
+
+{% block content %}
+{% if empty %}
+  <p id="nocomments">No comments to moderate.</div>
+{% else %}
+<div id="content-main">
+  <div class="module" id="changelist">
+    <table cellspacing="0">
+      <thead>
+        <tr>
+          <th>Action</th>
+          <th>Name</th>
+          <th>Comment</th>
+          <th>Email</th>
+          <th>URL</th>
+          <th>Authenticated?</th>
+          <th>IP Address</th>
+          <th class="sorted desc">Date posted</th>
+        </tr>
+    </thead>
+    <tbody>
+      {% for comment in comments %}
+        <tr class="{% cycle 'row1' 'row2' %}">
+          <td class="actions">
+            <form action="{% url comments-approve comment.pk %}" method="POST">
+              <input type="hidden" name="next" value="{% url comments-moderation-queue %}">
+              <input class="approve submit" type="submit" name="submit" value="Approve">
+            </form>
+            <form action="{% url comments-delete comment.pk %}" method="POST">
+              <input type="hidden" name="next" value="{% url comments-moderation-queue %}">
+              <input class="remove submit" type="submit" name="submit" value="Remove">
+            </form>
+          </td>
+          <td>{{ comment.name|escape }}</td>
+          <td>{{ comment.comment|truncatewords:"50"|escape }}</td>
+          <td>{{ comment.email|escape }}</td>
+          <td>{{ comment.url|escape }}</td>
+          <td>
+            <img
+              src="{% admin_media_prefix %}img/admin/icon-{% if comment.user %}yes{% else %}no{% endif %}.gif"
+              alt="{% if comment.user %}yes{% else %}no{% endif %}"
+            />
+          </td>
+          <td>{{ comment.ip_address|escape }}</td>
+          <td>{{ comment.submit_date|date:"F j, P" }}</td>
+        </tr>
+      {% endfor %}
+    </tbody>
+    </table>
+  </div>
+</div>
+{% endif %}
+{% endblock %}

+ 7 - 0
django/contrib/comments/templates/comments/posted.html

@@ -0,0 +1,7 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Thanks for commenting.{% endblock %}
+
+{% block content %}
+  <h1>Thank you for your comment.</h1>
+{% endblock %}

+ 34 - 0
django/contrib/comments/templates/comments/preview.html

@@ -0,0 +1,34 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Preview your comment{% endblock %}
+
+{% block content %}
+  {% load comments %}
+  <form action="{% comment_form_target %}" method="POST">
+    {% if form.errors %}
+      <h1>Please correct the error{{ form.errors|pluralize }} below</h1>
+    {% else %}
+      <h1>Preview your comment</h1>
+      <blockquote>{{ comment|escape|linebreaks }}</blockquote>
+      <p>
+        and <input type="submit" name="submit" value="Post your comment" id="submit"> or make changes:
+      </p>
+    {% endif %}
+    {% for field in form %}
+      {% if field.is_hidden %}
+        {{ field }}
+      {% else %}
+        <p
+          {% if field.errors %} class="error"{% endif %}
+          {% ifequal field.name "honeypot" %} style="display:none;"{% endifequal %}>
+          {% if field.errors %}{{ field.errors }}{% endif %}
+          {{ field.label_tag }} {{ field }}
+        </p>
+      {% endif %}
+    {% endfor %}
+    <p class="submit">
+      <input type="submit" name="submit" class="submit-post" value="Post">
+      <input type="submit" name="submit" class="submit-preview" value="Preview">
+    </p>
+  </form>
+{% endblock %}

+ 19 - 0
django/contrib/comments/templates/comments/reply.html

@@ -0,0 +1,19 @@
+{% load comments %}
+<form action="{% comment_form_target %}" method="POST">
+  {% for field in form %}
+    {% if field.is_hidden %}
+      {{ field }}
+    {% else %}
+      <p
+        {% if field.errors %} class="error"{% endif %}
+        {% ifequal field.name "honeypot" %} style="display:none;"{% endifequal %}>
+        {% if field.errors %}{{ field.errors }}{% endif %}
+        {{ field.label_tag }} {{ field }}
+      </p>
+    {% endif %}
+  {% endfor %}
+  <p class="submit">
+    <input type="submit" name="submit" class="submit-post" value="Reply">
+    <input type="submit" name="submit" class="submit-preview" value="Preview">
+  </p>
+</form>

+ 34 - 0
django/contrib/comments/templates/comments/reply_preview.html

@@ -0,0 +1,34 @@
+{% extends "comments/base.html" %}
+
+{% block title %}Preview your comment{% endblock %}
+
+{% block content %}
+  {% load comments %}
+  <form action="{% comment_form_target %}" method="POST">
+    {% if form.errors %}
+      <h1>Please correct the error{{ form.errors|pluralize }} below</h1>
+    {% else %}
+      <h1>Preview your comment</h1>
+      <blockquote>{{ comment|escape|linebreaks }}</blockquote>
+      <p>
+        and <input type="submit" name="submit" value="Post your comment" id="submit"> or make changes:
+      </p>
+    {% endif %}
+    {% for field in form %}
+      {% if field.is_hidden %}
+        {{ field }}
+      {% else %}
+        <p
+          {% if field.errors %} class="error"{% endif %}
+          {% ifequal field.name "honeypot" %} style="display:none;"{% endifequal %}>
+          {% if field.errors %}{{ field.errors }}{% endif %}
+          {{ field.label_tag }} {{ field }}
+        </p>
+      {% endif %}
+    {% endfor %}
+    <p class="submit">
+      <input type="submit" name="submit" class="submit-post" value="Post">
+      <input type="submit" name="submit" class="submit-preview" value="Preview">
+    </p>
+  </form>
+{% endblock %}

+ 201 - 282
django/contrib/comments/templatetags/comments.py

@@ -1,332 +1,251 @@
-from django.contrib.comments.models import Comment, FreeComment
-from django.contrib.comments.models import PHOTOS_REQUIRED, PHOTOS_OPTIONAL, RATINGS_REQUIRED, RATINGS_OPTIONAL, IS_PUBLIC
-from django.contrib.comments.models import MIN_PHOTO_DIMENSION, MAX_PHOTO_DIMENSION
 from django import template
 from django import template
-from django.template import loader
-from django.core.exceptions import ObjectDoesNotExist
+from django.template.loader import render_to_string
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.utils.encoding import smart_str
-import re
+from django.contrib import comments
 
 
 register = template.Library()
 register = template.Library()
 
 
-COMMENT_FORM = 'comments/form.html'
-FREE_COMMENT_FORM = 'comments/freeform.html'
-
-class CommentFormNode(template.Node):
-    def __init__(self, content_type, obj_id_lookup_var, obj_id, free,
-        photos_optional=False, photos_required=False, photo_options='',
-        ratings_optional=False, ratings_required=False, rating_options='',
-        is_public=True):
-        self.content_type = content_type
-        if obj_id_lookup_var is not None:
-            obj_id_lookup_var = template.Variable(obj_id_lookup_var)
-        self.obj_id_lookup_var, self.obj_id, self.free = obj_id_lookup_var, obj_id, free
-        self.photos_optional, self.photos_required = photos_optional, photos_required
-        self.ratings_optional, self.ratings_required = ratings_optional, ratings_required
-        self.photo_options, self.rating_options = photo_options, rating_options
-        self.is_public = is_public
+class BaseCommentNode(template.Node):
+    """
+    Base helper class (abstract) for handling the get_comment_* template tags.
+    Looks a bit strange, but the subclasses below should make this a bit more
+    obvious.
+    """
+
+    #@classmethod
+    def handle_token(cls, parser, token):
+        """Class method to parse get_comment_list/count/form and return a Node."""
+        tokens = token.contents.split()
+        if tokens[1] != 'for':
+            raise template.TemplateSyntaxError("Second argument in %r tag must be 'for'" % tokens[0])
+
+        # {% get_whatever for obj as varname %}
+        if len(tokens) == 5:
+            if tokens[3] != 'as':
+                raise template.TemplateSyntaxError("Third argument in %r must be 'as'" % tokens[0])
+            return cls(
+                object_expr = parser.compile_filter(tokens[2]),
+                as_varname = tokens[4],
+            )
+
+        # {% get_whatever for app.model pk as varname %}
+        elif len(tokens) == 6:
+            if tokens[4] != 'as':
+                raise template.TemplateSyntaxError("Fourth argument in %r must be 'as'" % tokens[0])
+            return cls(
+                ctype = BaseCommentNode.lookup_content_type(tokens[2], tokens[0]),
+                object_pk_expr = parser.compile_filter(tokens[3]),
+                as_varname = tokens[5]
+            )
+
+        else:
+            raise template.TemplateSyntaxError("%r tag requires 4 or 5 arguments" % tokens[0])
+
+    handle_token = classmethod(handle_token)
+
+    #@staticmethod
+    def lookup_content_type(token, tagname):
+        try:
+            app, model = token.split('.')
+            return ContentType.objects.get(app_label=app, model=model)
+        except ValueError:
+            raise template.TemplateSyntaxError("Third argument in %r must be in the format 'app.model'" % tagname)
+        except ContentType.DoesNotExist:
+            raise template.TemplateSyntaxError("%r tag has non-existant content-type: '%s.%s'" % (tagname, app, model))
+    lookup_content_type = staticmethod(lookup_content_type)
+
+    def __init__(self, ctype=None, object_pk_expr=None, object_expr=None, as_varname=None, comment=None):
+        if ctype is None and object_expr is None:
+            raise template.TemplateSyntaxError("Comment nodes must be given either a literal object or a ctype and object pk.")
+        self.comment_model = comments.get_model()
+        self.as_varname = as_varname
+        self.ctype = ctype
+        self.object_pk_expr = object_pk_expr
+        self.object_expr = object_expr
+        self.comment = comment
 
 
     def render(self, context):
     def render(self, context):
-        from django.conf import settings
-        from django.utils.text import normalize_newlines
-        import base64
-        context.push()
-        if self.obj_id_lookup_var is not None:
+        qs = self.get_query_set(context)
+        context[self.as_varname] = self.get_context_value_from_queryset(context, qs)
+        return ''
+
+    def get_query_set(self, context):
+        ctype, object_pk = self.get_target_ctype_pk(context)
+        if not object_pk:
+            return self.comment_model.objects.none()
+
+        qs = self.comment_model.objects.filter(
+            content_type = ctype,
+            object_pk    = object_pk,
+            site__pk     = settings.SITE_ID,
+            is_public    = True,
+        )
+        if settings.COMMENTS_HIDE_REMOVED:
+            qs = qs.filter(is_removed=False)
+
+        return qs
+
+    def get_target_ctype_pk(self, context):
+        if self.object_expr:
             try:
             try:
-                self.obj_id = self.obj_id_lookup_var.resolve(context)
+                obj = self.object_expr.resolve(context)
             except template.VariableDoesNotExist:
             except template.VariableDoesNotExist:
-                return ''
-            # Validate that this object ID is valid for this content-type.
-            # We only have to do this validation if obj_id_lookup_var is provided,
-            # because do_comment_form() validates hard-coded object IDs.
-            try:
-                self.content_type.get_object_for_this_type(pk=self.obj_id)
-            except ObjectDoesNotExist:
-                context['display_form'] = False
-            else:
-                context['display_form'] = True
+                return None, None
+            return ContentType.objects.get_for_model(obj), obj.pk
         else:
         else:
-            context['display_form'] = True
-        context['target'] = '%s:%s' % (self.content_type.id, self.obj_id)
-        options = []
-        for var, abbr in (('photos_required', PHOTOS_REQUIRED),
-                          ('photos_optional', PHOTOS_OPTIONAL),
-                          ('ratings_required', RATINGS_REQUIRED),
-                          ('ratings_optional', RATINGS_OPTIONAL),
-                          ('is_public', IS_PUBLIC)):
-            context[var] = getattr(self, var)
-            if getattr(self, var):
-                options.append(abbr)
-        context['options'] = ','.join(options)
-        if self.free:
-            context['hash'] = Comment.objects.get_security_hash(context['options'], '', '', context['target'])
-            default_form = loader.get_template(FREE_COMMENT_FORM)
+            return self.ctype, self.object_pk_expr.resolve(context, ignore_failures=True)
+
+    def get_context_value_from_queryset(self, context, qs):
+        """Subclasses should override this."""
+        raise NotImplementedError
+
+class CommentListNode(BaseCommentNode):
+    """Insert a list of comments into the context."""
+    def get_context_value_from_queryset(self, context, qs):
+        return list(qs)
+
+class CommentCountNode(BaseCommentNode):
+    """Insert a count of comments into the context."""
+    def get_context_value_from_queryset(self, context, qs):
+        return qs.count()
+
+class CommentFormNode(BaseCommentNode):
+    """Insert a form for the comment model into the context."""
+
+    def get_form(self, context):
+        ctype, object_pk = self.get_target_ctype_pk(context)
+        if object_pk:
+            return comments.get_form()(ctype.get_object_for_this_type(pk=object_pk))
         else:
         else:
-            context['photo_options'] = self.photo_options
-            context['rating_options'] = normalize_newlines(base64.encodestring(self.rating_options).strip())
-            if self.rating_options:
-                context['rating_range'], context['rating_choices'] = Comment.objects.get_rating_options(self.rating_options)
-            context['hash'] = Comment.objects.get_security_hash(context['options'], context['photo_options'], context['rating_options'], context['target'])
-            context['logout_url'] = settings.LOGOUT_URL
-            default_form = loader.get_template(COMMENT_FORM)
-        output = default_form.render(context)
-        context.pop()
-        return output
-
-class CommentCountNode(template.Node):
-    def __init__(self, package, module, context_var_name, obj_id, var_name, free):
-        self.package, self.module = package, module
-        if context_var_name is not None:
-            context_var_name = template.Variable(context_var_name)
-        self.context_var_name, self.obj_id = context_var_name, obj_id
-        self.var_name, self.free = var_name, free
+            return None
 
 
     def render(self, context):
     def render(self, context):
-        from django.conf import settings
-        manager = self.free and FreeComment.objects or Comment.objects
-        if self.context_var_name is not None:
-            self.obj_id = self.context_var_name.resolve(context)
-        comment_count = manager.filter(object_id__exact=self.obj_id,
-            content_type__app_label__exact=self.package,
-            content_type__model__exact=self.module, site__id__exact=settings.SITE_ID).count()
-        context[self.var_name] = comment_count
+        context[self.as_varname] = self.get_form(context)
         return ''
         return ''
 
 
-class CommentListNode(template.Node):
-    def __init__(self, package, module, context_var_name, obj_id, var_name, free, ordering, extra_kwargs=None):
-        self.package, self.module = package, module
-        if context_var_name is not None:
-            context_var_name = template.Variable(context_var_name)
-        self.context_var_name, self.obj_id = context_var_name, obj_id
-        self.var_name, self.free = var_name, free
-        self.ordering = ordering
-        self.extra_kwargs = extra_kwargs or {}
+class RenderCommentFormNode(CommentFormNode):
+    """Render the comment form directly"""
+
+    #@classmethod
+    def handle_token(cls, parser, token):
+        """Class method to parse render_comment_form and return a Node."""
+        tokens = token.contents.split()
+        if tokens[1] != 'for':
+            raise template.TemplateSyntaxError("Second argument in %r tag must be 'for'" % tokens[0])
+
+        # {% render_comment_form for obj %}
+        if len(tokens) == 3:
+            return cls(object_expr=parser.compile_filter(tokens[2]))
+
+        # {% render_comment_form for app.models pk %}
+        elif len(tokens) == 4:
+            return cls(
+                ctype = BaseCommentNode.lookup_content_type(tokens[2], tokens[0]),
+                object_pk_expr = parser.compile_filter(tokens[3])
+            )
+    handle_token = classmethod(handle_token)
 
 
     def render(self, context):
     def render(self, context):
-        from django.conf import settings
-        get_list_function = self.free and FreeComment.objects.filter or Comment.objects.get_list_with_karma
-        if self.context_var_name is not None:
-            try:
-                self.obj_id = self.context_var_name.resolve(context)
-            except template.VariableDoesNotExist:
-                return ''
-        kwargs = {
-            'object_id__exact': self.obj_id,
-            'content_type__app_label__exact': self.package,
-            'content_type__model__exact': self.module,
-            'site__id__exact': settings.SITE_ID,
-        }
-        kwargs.update(self.extra_kwargs)
-        comment_list = get_list_function(**kwargs).order_by(self.ordering + 'submit_date').select_related()
-        if not self.free and settings.COMMENTS_BANNED_USERS_GROUP:
-            comment_list = comment_list.extra(select={'is_hidden': 'user_id IN (SELECT user_id FROM auth_user_groups WHERE group_id = %s)' % settings.COMMENTS_BANNED_USERS_GROUP})
-
-        if not self.free:
-            if 'user' in context and context['user'].is_authenticated():
-                user_id = context['user'].id
-                context['user_can_moderate_comments'] = Comment.objects.user_is_moderator(context['user'])
-            else:
-                user_id = None
-                context['user_can_moderate_comments'] = False
-            # Only display comments by banned users to those users themselves.
-            if settings.COMMENTS_BANNED_USERS_GROUP:
-                comment_list = [c for c in comment_list if not c.is_hidden or (user_id == c.user_id)]
-
-        context[self.var_name] = comment_list
-        return ''
+        ctype, object_pk = self.get_target_ctype_pk(context)
+        if object_pk:
+            template_search_list = [
+                "comments/%s/%s/form.html" % (ctype.app_label, ctype.model),
+                "comments/%s/form.html" % ctype.app_label,
+                "comments/form.html"
+            ]
+            context.push()
+            formstr = render_to_string(template_search_list, {"form" : self.get_form(context)}, context)
+            context.pop()
+            return formstr
+        else:
+            return ''
+
+# We could just register each classmethod directly, but then we'd lose out on
+# the automagic docstrings-into-admin-docs tricks. So each node gets a cute
+# wrapper function that just exists to hold the docstring.
 
 
-class DoCommentForm:
+#@register.tag
+def get_comment_count(parser, token):
     """
     """
-    Displays a comment form for the given params.
+    Gets the comment count for the given params and populates the template
+    context with a variable containing that value, whose name is defined by the
+    'as' clause.
 
 
     Syntax::
     Syntax::
 
 
-        {% comment_form for [pkg].[py_module_name] [context_var_containing_obj_id] with [list of options] %}
+        {% get_comment_count for [object] as [varname]  %}
+        {% get_comment_count for [app].[model] [object_id] as [varname]  %}
 
 
     Example usage::
     Example usage::
 
 
-        {% comment_form for lcom.eventtimes event.id with is_public yes photos_optional thumbs,200,400 ratings_optional scale:1-5|first_option|second_option %}
+        {% get_comment_count for event as comment_count %}
+        {% get_comment_count for calendar.event event.id as comment_count %}
+        {% get_comment_count for calendar.event 17 as comment_count %}
 
 
-    ``[context_var_containing_obj_id]`` can be a hard-coded integer or a variable containing the ID.
     """
     """
-    def __init__(self, free):
-        self.free = free
+    return CommentCountNode.handle_token(parser, token)
 
 
-    def __call__(self, parser, token):
-        tokens = token.contents.split()
-        if len(tokens) < 4:
-            raise template.TemplateSyntaxError, "%r tag requires at least 3 arguments" % tokens[0]
-        if tokens[1] != 'for':
-            raise template.TemplateSyntaxError, "Second argument in %r tag must be 'for'" % tokens[0]
-        try:
-            package, module = tokens[2].split('.')
-        except ValueError: # unpack list of wrong size
-            raise template.TemplateSyntaxError, "Third argument in %r tag must be in the format 'package.module'" % tokens[0]
-        try:
-            content_type = ContentType.objects.get(app_label__exact=package, model__exact=module)
-        except ContentType.DoesNotExist:
-            raise template.TemplateSyntaxError, "%r tag has invalid content-type '%s.%s'" % (tokens[0], package, module)
-        obj_id_lookup_var, obj_id = None, None
-        if tokens[3].isdigit():
-            obj_id = tokens[3]
-            try: # ensure the object ID is valid
-                content_type.get_object_for_this_type(pk=obj_id)
-            except ObjectDoesNotExist:
-                raise template.TemplateSyntaxError, "%r tag refers to %s object with ID %s, which doesn't exist" % (tokens[0], content_type.name, obj_id)
-        else:
-            obj_id_lookup_var = tokens[3]
-        kwargs = {}
-        if len(tokens) > 4:
-            if tokens[4] != 'with':
-                raise template.TemplateSyntaxError, "Fourth argument in %r tag must be 'with'" % tokens[0]
-            for option, args in zip(tokens[5::2], tokens[6::2]):
-                option = smart_str(option)
-                if option in ('photos_optional', 'photos_required') and not self.free:
-                    # VALIDATION ##############################################
-                    option_list = args.split(',')
-                    if len(option_list) % 3 != 0:
-                        raise template.TemplateSyntaxError, "Incorrect number of comma-separated arguments to %r tag" % tokens[0]
-                    for opt in option_list[::3]:
-                        if not opt.isalnum():
-                            raise template.TemplateSyntaxError, "Invalid photo directory name in %r tag: '%s'" % (tokens[0], opt)
-                    for opt in option_list[1::3] + option_list[2::3]:
-                        if not opt.isdigit() or not (MIN_PHOTO_DIMENSION <= int(opt) <= MAX_PHOTO_DIMENSION):
-                            raise template.TemplateSyntaxError, "Invalid photo dimension in %r tag: '%s'. Only values between %s and %s are allowed." % (tokens[0], opt, MIN_PHOTO_DIMENSION, MAX_PHOTO_DIMENSION)
-                    # VALIDATION ENDS #########################################
-                    kwargs[option] = True
-                    kwargs['photo_options'] = args
-                elif option in ('ratings_optional', 'ratings_required') and not self.free:
-                    # VALIDATION ##############################################
-                    if 2 < len(args.split('|')) > 9:
-                        raise template.TemplateSyntaxError, "Incorrect number of '%s' options in %r tag. Use between 2 and 8." % (option, tokens[0])
-                    if re.match('^scale:\d+\-\d+\:$', args.split('|')[0]):
-                        raise template.TemplateSyntaxError, "Invalid 'scale' in %r tag's '%s' options" % (tokens[0], option)
-                    # VALIDATION ENDS #########################################
-                    kwargs[option] = True
-                    kwargs['rating_options'] = args
-                elif option in ('is_public'):
-                    kwargs[option] = (args == 'true')
-                else:
-                    raise template.TemplateSyntaxError, "%r tag got invalid parameter '%s'" % (tokens[0], option)
-        return CommentFormNode(content_type, obj_id_lookup_var, obj_id, self.free, **kwargs)
-
-class DoCommentCount:
+#@register.tag
+def get_comment_list(parser, token):
     """
     """
-    Gets comment count for the given params and populates the template context
-    with a variable containing that value, whose name is defined by the 'as'
-    clause.
+    Gets the list of comments for the given params and populates the template
+    context with a variable containing that value, whose name is defined by the
+    'as' clause.
 
 
     Syntax::
     Syntax::
 
 
-        {% get_comment_count for [pkg].[py_module_name] [context_var_containing_obj_id] as [varname]  %}
+        {% get_comment_list for [object] as [varname]  %}
+        {% get_comment_list for [app].[model] [object_id] as [varname]  %}
 
 
     Example usage::
     Example usage::
 
 
-        {% get_comment_count for lcom.eventtimes event.id as comment_count %}
+        {% get_comment_list for event as comment_list %}
+        {% for comment in comment_list %}
+            ...
+        {% endfor %}
 
 
-    Note: ``[context_var_containing_obj_id]`` can also be a hard-coded integer, like this::
-
-        {% get_comment_count for lcom.eventtimes 23 as comment_count %}
     """
     """
-    def __init__(self, free):
-        self.free = free
+    return CommentListNode.handle_token(parser, token)
 
 
-    def __call__(self, parser, token):
-        tokens = token.contents.split()
-        # Now tokens is a list like this:
-        # ['get_comment_list', 'for', 'lcom.eventtimes', 'event.id', 'as', 'comment_list']
-        if len(tokens) != 6:
-            raise template.TemplateSyntaxError, "%r tag requires 5 arguments" % tokens[0]
-        if tokens[1] != 'for':
-            raise template.TemplateSyntaxError, "Second argument in %r tag must be 'for'" % tokens[0]
-        try:
-            package, module = tokens[2].split('.')
-        except ValueError: # unpack list of wrong size
-            raise template.TemplateSyntaxError, "Third argument in %r tag must be in the format 'package.module'" % tokens[0]
-        try:
-            content_type = ContentType.objects.get(app_label__exact=package, model__exact=module)
-        except ContentType.DoesNotExist:
-            raise template.TemplateSyntaxError, "%r tag has invalid content-type '%s.%s'" % (tokens[0], package, module)
-        var_name, obj_id = None, None
-        if tokens[3].isdigit():
-            obj_id = tokens[3]
-            try: # ensure the object ID is valid
-                content_type.get_object_for_this_type(pk=obj_id)
-            except ObjectDoesNotExist:
-                raise template.TemplateSyntaxError, "%r tag refers to %s object with ID %s, which doesn't exist" % (tokens[0], content_type.name, obj_id)
-        else:
-            var_name = tokens[3]
-        if tokens[4] != 'as':
-            raise template.TemplateSyntaxError, "Fourth argument in %r must be 'as'" % tokens[0]
-        return CommentCountNode(package, module, var_name, obj_id, tokens[5], self.free)
-
-class DoGetCommentList:
+#@register.tag
+def get_comment_form(parser, token):
     """
     """
-    Gets comments for the given params and populates the template context with a
-    special comment_package variable, whose name is defined by the ``as``
-    clause.
+    Get a (new) form object to post a new comment.
 
 
     Syntax::
     Syntax::
 
 
-        {% get_comment_list for [pkg].[py_module_name] [context_var_containing_obj_id] as [varname] (reversed) %}
+        {% get_comment_form for [object] as [varname] %}
+        {% get_comment_form for [app].[model] [object_id] as [varname] %}
+    """
+    return CommentFormNode.handle_token(parser, token)
 
 
-    Example usage::
+#@register.tag
+def render_comment_form(parser, token):
+    """
+    Render the comment form (as returned by ``{% render_comment_form %}``) through
+    the ``comments/form.html`` template.
 
 
-        {% get_comment_list for lcom.eventtimes event.id as comment_list %}
+    Syntax::
 
 
-    Note: ``[context_var_containing_obj_id]`` can also be a hard-coded integer, like this::
+        {% render_comment_form for [object] %}
+        {% render_comment_form for [app].[model] [object_id] %}
+    """
+    return RenderCommentFormNode.handle_token(parser, token)
 
 
-        {% get_comment_list for lcom.eventtimes 23 as comment_list %}
+#@register.simple_tag
+def comment_form_target():
+    """
+    Get the target URL for the comment form.
 
 
-    To get a list of comments in reverse order -- that is, most recent first --
-    pass ``reversed`` as the last param::
+    Example::
 
 
-        {% get_comment_list for lcom.eventtimes event.id as comment_list reversed %}
+        <form action="{% comment_form_target %}" method="POST">
     """
     """
-    def __init__(self, free):
-        self.free = free
+    return comments.get_form_target()
 
 
-    def __call__(self, parser, token):
-        tokens = token.contents.split()
-        # Now tokens is a list like this:
-        # ['get_comment_list', 'for', 'lcom.eventtimes', 'event.id', 'as', 'comment_list']
-        if not len(tokens) in (6, 7):
-            raise template.TemplateSyntaxError, "%r tag requires 5 or 6 arguments" % tokens[0]
-        if tokens[1] != 'for':
-            raise template.TemplateSyntaxError, "Second argument in %r tag must be 'for'" % tokens[0]
-        try:
-            package, module = tokens[2].split('.')
-        except ValueError: # unpack list of wrong size
-            raise template.TemplateSyntaxError, "Third argument in %r tag must be in the format 'package.module'" % tokens[0]
-        try:
-            content_type = ContentType.objects.get(app_label__exact=package,model__exact=module)
-        except ContentType.DoesNotExist:
-            raise template.TemplateSyntaxError, "%r tag has invalid content-type '%s.%s'" % (tokens[0], package, module)
-        var_name, obj_id = None, None
-        if tokens[3].isdigit():
-            obj_id = tokens[3]
-            try: # ensure the object ID is valid
-                content_type.get_object_for_this_type(pk=obj_id)
-            except ObjectDoesNotExist:
-                raise template.TemplateSyntaxError, "%r tag refers to %s object with ID %s, which doesn't exist" % (tokens[0], content_type.name, obj_id)
-        else:
-            var_name = tokens[3]
-        if tokens[4] != 'as':
-            raise template.TemplateSyntaxError, "Fourth argument in %r must be 'as'" % tokens[0]
-        if len(tokens) == 7:
-            if tokens[6] != 'reversed':
-                raise template.TemplateSyntaxError, "Final argument in %r must be 'reversed' if given" % tokens[0]
-            ordering = "-"
-        else:
-            ordering = ""
-        return CommentListNode(package, module, var_name, obj_id, tokens[5], self.free, ordering)
-
-# registration comments
-register.tag('get_comment_list', DoGetCommentList(False))
-register.tag('comment_form', DoCommentForm(False))
-register.tag('get_comment_count', DoCommentCount(False))
-# free comments
-register.tag('get_free_comment_list', DoGetCommentList(True))
-register.tag('free_comment_form', DoCommentForm(True))
-register.tag('get_free_comment_count', DoCommentCount(True))
+register.tag(get_comment_count)
+register.tag(get_comment_list)
+register.tag(get_comment_form)
+register.tag(render_comment_form)
+register.simple_tag(comment_form_target)

+ 0 - 13
django/contrib/comments/tests.py

@@ -1,13 +0,0 @@
-# coding: utf-8
-
-r"""
->>> from django.contrib.comments.models import Comment
->>> from django.contrib.auth.models import User
->>> u = User.objects.create_user('commenttestuser', 'commenttest@example.com', 'testpw')
->>> c = Comment(user=u, comment=u'\xe2')
->>> c
-<Comment: commenttestuser: â...>
->>> print c
-commenttestuser: â...
-"""
-

+ 15 - 0
django/contrib/comments/urls.py

@@ -0,0 +1,15 @@
+from django.conf.urls.defaults import *
+from django.conf import settings
+
+urlpatterns = patterns('django.contrib.comments.views',
+    url(r'^post/$',          'comments.post_comment',       name='comments-post-comment'),
+    url(r'^posted/$',        'comments.comment_done',       name='comments-comment-done'),
+    url(r'^flag/(\d+)/$',    'moderation.flag',             name='comments-flag'),
+    url(r'^flagged/$',       'moderation.flag_done',        name='comments-flag-done'),
+    url(r'^delete/(\d+)/$',  'moderation.delete',           name='comments-delete'),
+    url(r'^deleted/$',       'moderation.delete_done',      name='comments-delete-done'),
+    url(r'^moderate/$',      'moderation.moderation_queue', name='comments-moderation-queue'),
+    url(r'^approve/(\d+)/$', 'moderation.approve',          name='comments-approve'),
+    url(r'^approved/$',      'moderation.approve_done',     name='comments-approve-done'),
+)
+

+ 0 - 12
django/contrib/comments/urls/comments.py

@@ -1,12 +0,0 @@
-from django.conf.urls.defaults import *
-
-urlpatterns = patterns('django.contrib.comments.views',
-    (r'^post/$', 'comments.post_comment'),
-    (r'^postfree/$', 'comments.post_free_comment'),
-    (r'^posted/$', 'comments.comment_was_posted'),
-    (r'^karma/vote/(?P<comment_id>\d+)/(?P<vote>up|down)/$', 'karma.vote'),
-    (r'^flag/(?P<comment_id>\d+)/$', 'userflags.flag'),
-    (r'^flag/(?P<comment_id>\d+)/done/$', 'userflags.flag_done'),
-    (r'^delete/(?P<comment_id>\d+)/$', 'userflags.delete'),
-    (r'^delete/(?P<comment_id>\d+)/done/$', 'userflags.delete_done'),
-)

+ 99 - 376
django/contrib/comments/views/comments.py

@@ -1,393 +1,116 @@
-import base64
-import datetime
-
-from django.core import validators
-from django import oldforms
-from django.core.mail import mail_admins, mail_managers
-from django.http import Http404
+from django import http
+from django.conf import settings
+from utils import next_redirect, confirmation_view
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
 from django.shortcuts import render_to_response
 from django.shortcuts import render_to_response
 from django.template import RequestContext
 from django.template import RequestContext
-from django.contrib.comments.models import Comment, FreeComment, RATINGS_REQUIRED, RATINGS_OPTIONAL, IS_PUBLIC
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.auth import authenticate
-from django.http import HttpResponseRedirect
-from django.utils.text import normalize_newlines
-from django.conf import settings
-from django.utils.translation import ungettext, ugettext as _
-from django.utils.encoding import smart_unicode
-
-COMMENTS_PER_PAGE = 20
-
-# TODO: This is a copy of the manipulator-based form that used to live in
-# contrib.auth.forms.  It should be replaced with the newforms version that
-# has now been added to contrib.auth.forms when the comments app gets updated
-# for newforms.
+from django.template.loader import render_to_string
+from django.utils.html import escape
+from django.contrib import comments
+from django.contrib.comments import signals
 
 
-class AuthenticationForm(oldforms.Manipulator):
+class CommentPostBadRequest(http.HttpResponseBadRequest):
     """
     """
-    Base class for authenticating users. Extend this to get a form that accepts
-    username/password logins.
+    Response returned when a comment post is invalid. If ``DEBUG`` is on a
+    nice-ish error message will be displayed (for debugging purposes), but in
+    production mode a simple opaque 400 page will be displayed.
     """
     """
-    def __init__(self, request=None):
-        """
-        If request is passed in, the manipulator will validate that cookies are
-        enabled. Note that the request (a HttpRequest object) must have set a
-        cookie with the key TEST_COOKIE_NAME and value TEST_COOKIE_VALUE before
-        running this validator.
-        """
-        self.request = request
-        self.fields = [
-            oldforms.TextField(field_name="username", length=15, max_length=30, is_required=True,
-                validator_list=[self.isValidUser, self.hasCookiesEnabled]),
-            oldforms.PasswordField(field_name="password", length=15, max_length=30, is_required=True),
-        ]
-        self.user_cache = None
-
-    def hasCookiesEnabled(self, field_data, all_data):
-        if self.request and not self.request.session.test_cookie_worked():
-            raise validators.ValidationError, _("Your Web browser doesn't appear to have cookies enabled. Cookies are required for logging in.")
-
-    def isValidUser(self, field_data, all_data):
-        username = field_data
-        password = all_data.get('password', None)
-        self.user_cache = authenticate(username=username, password=password)
-        if self.user_cache is None:
-            raise validators.ValidationError, _("Please enter a correct username and password. Note that both fields are case-sensitive.")
-        elif not self.user_cache.is_active:
-            raise validators.ValidationError, _("This account is inactive.")
-
-    def get_user_id(self):
-        if self.user_cache:
-            return self.user_cache.id
-        return None
-
-    def get_user(self):
-        return self.user_cache
-
-class PublicCommentManipulator(AuthenticationForm):
-    "Manipulator that handles public registered comments"
-    def __init__(self, user, ratings_required, ratings_range, num_rating_choices):
-        AuthenticationForm.__init__(self)
-        self.ratings_range, self.num_rating_choices = ratings_range, num_rating_choices
-        choices = [(c, c) for c in ratings_range]
-        def get_validator_list(rating_num):
-            if rating_num <= num_rating_choices:
-                return [validators.RequiredIfOtherFieldsGiven(['rating%d' % i for i in range(1, 9) if i != rating_num], _("This rating is required because you've entered at least one other rating."))]
-            else:
-                return []
-        self.fields.extend([
-            oldforms.LargeTextField(field_name="comment", max_length=3000, is_required=True,
-                validator_list=[self.hasNoProfanities]),
-            oldforms.RadioSelectField(field_name="rating1", choices=choices,
-                is_required=ratings_required and num_rating_choices > 0,
-                validator_list=get_validator_list(1),
-            ),
-            oldforms.RadioSelectField(field_name="rating2", choices=choices,
-                is_required=ratings_required and num_rating_choices > 1,
-                validator_list=get_validator_list(2),
-            ),
-            oldforms.RadioSelectField(field_name="rating3", choices=choices,
-                is_required=ratings_required and num_rating_choices > 2,
-                validator_list=get_validator_list(3),
-            ),
-            oldforms.RadioSelectField(field_name="rating4", choices=choices,
-                is_required=ratings_required and num_rating_choices > 3,
-                validator_list=get_validator_list(4),
-            ),
-            oldforms.RadioSelectField(field_name="rating5", choices=choices,
-                is_required=ratings_required and num_rating_choices > 4,
-                validator_list=get_validator_list(5),
-            ),
-            oldforms.RadioSelectField(field_name="rating6", choices=choices,
-                is_required=ratings_required and num_rating_choices > 5,
-                validator_list=get_validator_list(6),
-            ),
-            oldforms.RadioSelectField(field_name="rating7", choices=choices,
-                is_required=ratings_required and num_rating_choices > 6,
-                validator_list=get_validator_list(7),
-            ),
-            oldforms.RadioSelectField(field_name="rating8", choices=choices,
-                is_required=ratings_required and num_rating_choices > 7,
-                validator_list=get_validator_list(8),
-            ),
-        ])
-        if user.is_authenticated():
-            self["username"].is_required = False
-            self["username"].validator_list = []
-            self["password"].is_required = False
-            self["password"].validator_list = []
-            self.user_cache = user
-
-    def hasNoProfanities(self, field_data, all_data):
-        if settings.COMMENTS_ALLOW_PROFANITIES:
-            return
-        return validators.hasNoProfanities(field_data, all_data)
-
-    def get_comment(self, new_data):
-        "Helper function"
-        return Comment(None, self.get_user_id(), new_data["content_type_id"],
-            new_data["object_id"], new_data.get("headline", "").strip(),
-            new_data["comment"].strip(), new_data.get("rating1", None),
-            new_data.get("rating2", None), new_data.get("rating3", None),
-            new_data.get("rating4", None), new_data.get("rating5", None),
-            new_data.get("rating6", None), new_data.get("rating7", None),
-            new_data.get("rating8", None), new_data.get("rating1", None) is not None,
-            datetime.datetime.now(), new_data["is_public"], new_data["ip_address"], False, settings.SITE_ID)
-
-    def save(self, new_data):
-        today = datetime.date.today()
-        c = self.get_comment(new_data)
-        for old in Comment.objects.filter(content_type__id__exact=new_data["content_type_id"],
-            object_id__exact=new_data["object_id"], user__id__exact=self.get_user_id()):
-            # Check that this comment isn't duplicate. (Sometimes people post
-            # comments twice by mistake.) If it is, fail silently by pretending
-            # the comment was posted successfully.
-            if old.submit_date.date() == today and old.comment == c.comment \
-                and old.rating1 == c.rating1 and old.rating2 == c.rating2 \
-                and old.rating3 == c.rating3 and old.rating4 == c.rating4 \
-                and old.rating5 == c.rating5 and old.rating6 == c.rating6 \
-                and old.rating7 == c.rating7 and old.rating8 == c.rating8:
-                return old
-            # If the user is leaving a rating, invalidate all old ratings.
-            if c.rating1 is not None:
-                old.valid_rating = False
-                old.save()
-        c.save()
-        # If the commentor has posted fewer than COMMENTS_FIRST_FEW comments,
-        # send the comment to the managers.
-        if self.user_cache.comment_set.count() <= settings.COMMENTS_FIRST_FEW:
-            message = ungettext('This comment was posted by a user who has posted fewer than %(count)s comment:\n\n%(text)s',
-                'This comment was posted by a user who has posted fewer than %(count)s comments:\n\n%(text)s', settings.COMMENTS_FIRST_FEW) % \
-                {'count': settings.COMMENTS_FIRST_FEW, 'text': c.get_as_text()}
-            mail_managers("Comment posted by rookie user", message)
-        if settings.COMMENTS_SKETCHY_USERS_GROUP and settings.COMMENTS_SKETCHY_USERS_GROUP in [g.id for g in self.user_cache.groups.all()]:
-            message = _('This comment was posted by a sketchy user:\n\n%(text)s') % {'text': c.get_as_text()}
-            mail_managers("Comment posted by sketchy user (%s)" % self.user_cache.username, c.get_as_text())
-        return c
-
-class PublicFreeCommentManipulator(oldforms.Manipulator):
-    "Manipulator that handles public free (unregistered) comments"
-    def __init__(self):
-        self.fields = (
-            oldforms.TextField(field_name="person_name", max_length=50, is_required=True,
-                validator_list=[self.hasNoProfanities]),
-            oldforms.LargeTextField(field_name="comment", max_length=3000, is_required=True,
-                validator_list=[self.hasNoProfanities]),
-        )
-
-    def hasNoProfanities(self, field_data, all_data):
-        if settings.COMMENTS_ALLOW_PROFANITIES:
-            return
-        return validators.hasNoProfanities(field_data, all_data)
+    def __init__(self, why):
+        super(CommentPostBadRequest, self).__init__()
+        if settings.DEBUG:
+            self.content = render_to_string("comments/400-debug.html", {"why": why})
 
 
-    def get_comment(self, new_data):
-        "Helper function"
-        return FreeComment(None, new_data["content_type_id"],
-            new_data["object_id"], new_data["comment"].strip(),
-            new_data["person_name"].strip(), datetime.datetime.now(), new_data["is_public"],
-            new_data["ip_address"], False, settings.SITE_ID)
-
-    def save(self, new_data):
-        today = datetime.date.today()
-        c = self.get_comment(new_data)
-        # Check that this comment isn't duplicate. (Sometimes people post
-        # comments twice by mistake.) If it is, fail silently by pretending
-        # the comment was posted successfully.
-        for old_comment in FreeComment.objects.filter(content_type__id__exact=new_data["content_type_id"],
-            object_id__exact=new_data["object_id"], person_name__exact=new_data["person_name"],
-            submit_date__year=today.year, submit_date__month=today.month,
-            submit_date__day=today.day):
-            if old_comment.comment == c.comment:
-                return old_comment
-        c.save()
-        return c
-
-def post_comment(request, extra_context=None, context_processors=None):
+def post_comment(request, next=None):
     """
     """
-    Post a comment
+    Post a comment.
 
 
-    Redirects to the `comments.comments.comment_was_posted` view upon success.
-
-    Templates: `comment_preview`
-    Context:
-        comment
-            the comment being posted
-        comment_form
-            the comment form
-        options
-            comment options
-        target
-            comment target
-        hash
-            security hash (must be included in a posted form to succesfully
-            post a comment).
-        rating_options
-            comment ratings options
-        ratings_optional
-            are ratings optional?
-        ratings_required
-            are ratings required?
-        rating_range
-            range of ratings
-        rating_choices
-            choice of ratings
+    HTTP POST is required. If ``POST['submit'] == "preview"`` or if there are
+    errors a preview template, ``comments/preview.html``, will be rendered.
     """
     """
-    if extra_context is None: extra_context = {}
-    if not request.POST:
-        raise Http404, _("Only POSTs are allowed")
-    try:
-        options, target, security_hash = request.POST['options'], request.POST['target'], request.POST['gonzo']
-    except KeyError:
-        raise Http404, _("One or more of the required fields wasn't submitted")
-    photo_options = request.POST.get('photo_options', '')
-    rating_options = normalize_newlines(request.POST.get('rating_options', ''))
-    if Comment.objects.get_security_hash(options, photo_options, rating_options, target) != security_hash:
-        raise Http404, _("Somebody tampered with the comment form (security violation)")
-    # Now we can be assured the data is valid.
-    if rating_options:
-        rating_range, rating_choices = Comment.objects.get_rating_options(base64.decodestring(rating_options))
-    else:
-        rating_range, rating_choices = [], []
-    content_type_id, object_id = target.split(':') # target is something like '52:5157'
+
+    # Require POST
+    if request.method != 'POST':
+        return http.HttpResponseNotAllowed(["POST"])
+
+    # Fill out some initial data fields from an authenticated user, if present
+    data = request.POST.copy()
+    if request.user.is_authenticated():
+        if "name" not in data:
+            data["name"] = request.user.get_full_name()
+        if "email" not in data:
+            data["email"] = request.user.email
+
+    # Look up the object we're trying to comment about
+    ctype = data.get("content_type")
+    object_pk = data.get("object_pk")
+    if ctype is None or object_pk is None:
+        return CommentPostBadRequest("Missing content_type or object_pk field.")
     try:
     try:
-        obj = ContentType.objects.get(pk=content_type_id).get_object_for_this_type(pk=object_id)
+        model = models.get_model(*ctype.split(".", 1))
+        target = model._default_manager.get(pk=object_pk)
+    except TypeError:
+        return CommentPostBadRequest(
+            "Invalid content_type value: %r" % escape(ctype))
+    except AttributeError:
+        return CommentPostBadRequest(
+            "The given content-type %r does not resolve to a valid model." % \
+                escape(ctype))
     except ObjectDoesNotExist:
     except ObjectDoesNotExist:
-        raise Http404, _("The comment form had an invalid 'target' parameter -- the object ID was invalid")
-    option_list = options.split(',') # options is something like 'pa,ra'
-    new_data = request.POST.copy()
-    new_data['content_type_id'] = content_type_id
-    new_data['object_id'] = object_id
-    new_data['ip_address'] = request.META.get('REMOTE_ADDR')
-    new_data['is_public'] = IS_PUBLIC in option_list
-    manipulator = PublicCommentManipulator(request.user,
-        ratings_required=RATINGS_REQUIRED in option_list,
-        ratings_range=rating_range,
-        num_rating_choices=len(rating_choices))
-    errors = manipulator.get_validation_errors(new_data)
-    # If user gave correct username/password and wasn't already logged in, log them in
-    # so they don't have to enter a username/password again.
-    if manipulator.get_user() and not manipulator.get_user().is_authenticated() and 'password' in new_data and manipulator.get_user().check_password(new_data['password']):
-        from django.contrib.auth import login
-        login(request, manipulator.get_user())
-    if errors or 'preview' in request.POST:
-        class CommentFormWrapper(oldforms.FormWrapper):
-            def __init__(self, manipulator, new_data, errors, rating_choices):
-                oldforms.FormWrapper.__init__(self, manipulator, new_data, errors)
-                self.rating_choices = rating_choices
-            def ratings(self):
-                field_list = [self['rating%d' % (i+1)] for i in range(len(rating_choices))]
-                for i, f in enumerate(field_list):
-                    f.choice = rating_choices[i]
-                return field_list
-        comment = errors and '' or manipulator.get_comment(new_data)
-        comment_form = CommentFormWrapper(manipulator, new_data, errors, rating_choices)
-        return render_to_response('comments/preview.html', {
-            'comment': comment,
-            'comment_form': comment_form,
-            'options': options,
-            'target': target,
-            'hash': security_hash,
-            'rating_options': rating_options,
-            'ratings_optional': RATINGS_OPTIONAL in option_list,
-            'ratings_required': RATINGS_REQUIRED in option_list,
-            'rating_range': rating_range,
-            'rating_choices': rating_choices,
-        }, context_instance=RequestContext(request, extra_context, context_processors))
-    elif 'post' in request.POST:
-        # If the IP is banned, mail the admins, do NOT save the comment, and
-        # serve up the "Thanks for posting" page as if the comment WAS posted.
-        if request.META['REMOTE_ADDR'] in settings.BANNED_IPS:
-            mail_admins("Banned IP attempted to post comment", smart_unicode(request.POST) + "\n\n" + str(request.META))
-        else:
-            manipulator.do_html2python(new_data)
-            comment = manipulator.save(new_data)
-        return HttpResponseRedirect("../posted/?c=%s:%s" % (content_type_id, object_id))
-    else:
-        raise Http404, _("The comment form didn't provide either 'preview' or 'post'")
+        return CommentPostBadRequest(
+            "No object matching content-type %r and object PK %r exists." % \
+                (escape(ctype), escape(object_pk)))
+
+    # Do we want to preview the comment?
+    preview = data.get("submit", "").lower() == "preview" or \
+              data.get("preview", None) is not None
+
+    # Construct the comment form
+    form = comments.get_form()(target, data=data)
+
+    # Check security information
+    if form.security_errors():
+        return CommentPostBadRequest(
+            "The comment form failed security verification: %s" % \
+                escape(str(form.security_errors())))
+
+    # If there are errors or if we requested a preview show the comment
+    if form.errors or preview:
+        template_list = [
+            "comments/%s_%s_preview.html" % tuple(str(model._meta).split(".")),
+            "comments/%s_preview.html" % model._meta.app_label,
+            "comments/preview.html",
+        ]
+        return render_to_response(
+            template_list, {
+                "comment" : form.data.get("comment", ""),
+                "form" : form,
+            }, 
+            RequestContext(request, {})
+        )
 
 
-def post_free_comment(request, extra_context=None, context_processors=None):
-    """
-    Post a free comment (not requiring a log in)
+    # Otherwise create the comment
+    comment = form.get_comment_object()
+    comment.ip_address = request.META.get("REMOTE_ADDR", None)
+    if request.user.is_authenticated():
+        comment.user = request.user
 
 
-    Redirects to `comments.comments.comment_was_posted` view on success.
+    # Signal that the comment is about to be saved
+    responses = signals.comment_will_be_posted.send(comment)
 
 
-    Templates: `comment_free_preview`
-    Context:
-        comment
-            comment being posted
-        comment_form
-            comment form object
-        options
-            comment options
-        target
-            comment target
-        hash
-            security hash (must be included in a posted form to succesfully
-            post a comment).
-    """
-    if extra_context is None: extra_context = {}
-    if not request.POST:
-        raise Http404, _("Only POSTs are allowed")
-    try:
-        options, target, security_hash = request.POST['options'], request.POST['target'], request.POST['gonzo']
-    except KeyError:
-        raise Http404, _("One or more of the required fields wasn't submitted")
-    if Comment.objects.get_security_hash(options, '', '', target) != security_hash:
-        raise Http404, _("Somebody tampered with the comment form (security violation)")
-    content_type_id, object_id = target.split(':') # target is something like '52:5157'
-    content_type = ContentType.objects.get(pk=content_type_id)
-    try:
-        obj = content_type.get_object_for_this_type(pk=object_id)
-    except ObjectDoesNotExist:
-        raise Http404, _("The comment form had an invalid 'target' parameter -- the object ID was invalid")
-    option_list = options.split(',')
-    new_data = request.POST.copy()
-    new_data['content_type_id'] = content_type_id
-    new_data['object_id'] = object_id
-    new_data['ip_address'] = request.META['REMOTE_ADDR']
-    new_data['is_public'] = IS_PUBLIC in option_list
-    manipulator = PublicFreeCommentManipulator()
-    errors = manipulator.get_validation_errors(new_data)
-    if errors or 'preview' in request.POST:
-        comment = errors and '' or manipulator.get_comment(new_data)
-        return render_to_response('comments/free_preview.html', {
-            'comment': comment,
-            'comment_form': oldforms.FormWrapper(manipulator, new_data, errors),
-            'options': options,
-            'target': target,
-            'hash': security_hash,
-        }, context_instance=RequestContext(request, extra_context, context_processors))
-    elif 'post' in request.POST:
-        # If the IP is banned, mail the admins, do NOT save the comment, and
-        # serve up the "Thanks for posting" page as if the comment WAS posted.
-        if request.META['REMOTE_ADDR'] in settings.BANNED_IPS:
-            from django.core.mail import mail_admins
-            mail_admins("Practical joker", smart_unicode(request.POST) + "\n\n" + str(request.META))
-        else:
-            manipulator.do_html2python(new_data)
-            comment = manipulator.save(new_data)
-        return HttpResponseRedirect("../posted/?c=%s:%s" % (content_type_id, object_id))
-    else:
-        raise Http404, _("The comment form didn't provide either 'preview' or 'post'")
+    for (receiver, response) in responses:
+        if response == False:
+            return CommentPostBadRequest(
+                "comment_will_be_posted receiver %r killed the comment" % receiver.__name__)
 
 
-def comment_was_posted(request, extra_context=None, context_processors=None):
-    """
-    Display "comment was posted" success page
+    # Save the comment and signal that it was saved
+    comment.save()
+    signals.comment_was_posted.send(comment)
+
+    return next_redirect(data, next, comment_done, c=comment._get_pk_val())
+
+comment_done = confirmation_view(
+    template = "comments/posted.html",
+    doc = """Display a "comment was posted" success page."""
+)
 
 
-    Templates: `comment_posted`
-    Context:
-        object
-            The object the comment was posted on
-    """
-    if extra_context is None: extra_context = {}
-    obj = None
-    if 'c' in request.GET:
-        content_type_id, object_id = request.GET['c'].split(':')
-        try:
-            content_type = ContentType.objects.get(pk=content_type_id)
-            obj = content_type.get_object_for_this_type(pk=object_id)
-        except ObjectDoesNotExist:
-            pass
-    return render_to_response('comments/posted.html', {'object': obj},
-        context_instance=RequestContext(request, extra_context, context_processors))

+ 0 - 32
django/contrib/comments/views/karma.py

@@ -1,32 +0,0 @@
-from django.http import Http404
-from django.shortcuts import render_to_response
-from django.template import RequestContext
-from django.contrib.comments.models import Comment, KarmaScore
-from django.utils.translation import ugettext as _
-
-def vote(request, comment_id, vote, extra_context=None, context_processors=None):
-    """
-    Rate a comment (+1 or -1)
-
-    Templates: `karma_vote_accepted`
-    Context:
-        comment
-            `comments.comments` object being rated
-    """
-    if extra_context is None: extra_context = {}
-    rating = {'up': 1, 'down': -1}.get(vote, False)
-    if not rating:
-        raise Http404, "Invalid vote"
-    if not request.user.is_authenticated():
-        raise Http404, _("Anonymous users cannot vote")
-    try:
-        comment = Comment.objects.get(pk=comment_id)
-    except Comment.DoesNotExist:
-        raise Http404, _("Invalid comment ID")
-    if comment.user.id == request.user.id:
-        raise Http404, _("No voting for yourself")
-    KarmaScore.objects.vote(request.user.id, comment_id, rating)
-    # Reload comment to ensure we have up to date karma count
-    comment = Comment.objects.get(pk=comment_id)
-    return render_to_response('comments/karma_vote_accepted.html', {'comment': comment},
-        context_instance=RequestContext(request, extra_context, context_processors))

+ 186 - 0
django/contrib/comments/views/moderation.py

@@ -0,0 +1,186 @@
+from django import template
+from django.conf import settings
+from django.shortcuts import get_object_or_404, render_to_response
+from django.contrib.auth.decorators import login_required, permission_required
+from utils import next_redirect, confirmation_view
+from django.core.paginator import Paginator, InvalidPage
+from django.http import Http404
+from django.contrib import comments
+from django.contrib.comments import signals
+
+#@login_required
+def flag(request, comment_id, next=None):
+    """
+    Flags a comment. Confirmation on GET, action on POST.
+
+    Templates: `comments/flag.html`,
+    Context:
+        comment
+            the flagged `comments.comment` object
+    """
+    comment = get_object_or_404(comments.get_model(), pk=comment_id, site__pk=settings.SITE_ID)
+
+    # Flag on POST
+    if request.method == 'POST':
+        flag, created = comments.models.CommentFlag.objects.get_or_create(
+            comment = comment,
+            user    = request.user,
+            flag    = comments.models.CommentFlag.SUGGEST_REMOVAL
+        )
+        signals.comment_was_flagged.send(comment)
+        return next_redirect(request.POST.copy(), next, flag_done, c=comment.pk)
+
+    # Render a form on GET
+    else:
+        return render_to_response('comments/flag.html',
+            {'comment': comment, "next": next},
+            template.RequestContext(request)
+        )
+flag = login_required(flag)
+
+#@permission_required("comments.delete_comment")
+def delete(request, comment_id, next=None):
+    """
+    Deletes a comment. Confirmation on GET, action on POST. Requires the "can
+    moderate comments" permission.
+
+    Templates: `comments/delete.html`,
+    Context:
+        comment
+            the flagged `comments.comment` object
+    """
+    comment = get_object_or_404(comments.get_model(), pk=comment_id, site__pk=settings.SITE_ID)
+
+    # Delete on POST
+    if request.method == 'POST':
+        # Flag the comment as deleted instead of actually deleting it.
+        flag, created = comments.models.CommentFlag.objects.get_or_create(
+            comment = comment,
+            user    = request.user,
+            flag    = comments.models.CommentFlag.MODERATOR_DELETION
+        )
+        comment.is_removed = True
+        comment.save()
+        signals.comment_was_flagged.send(comment)
+        return next_redirect(request.POST.copy(), next, delete_done, c=comment.pk)
+
+    # Render a form on GET
+    else:
+        return render_to_response('comments/delete.html',
+            {'comment': comment, "next": next},
+            template.RequestContext(request)
+        )
+delete = permission_required("comments.can_moderate")(delete)
+
+#@permission_required("comments.can_moderate")
+def approve(request, comment_id, next=None):
+    """
+    Approve a comment (that is, mark it as public and non-removed). Confirmation
+    on GET, action on POST. Requires the "can moderate comments" permission.
+
+    Templates: `comments/approve.html`,
+    Context:
+        comment
+            the `comments.comment` object for approval
+    """
+    comment = get_object_or_404(comments.get_model(), pk=comment_id, site__pk=settings.SITE_ID)
+
+    # Delete on POST
+    if request.method == 'POST':
+        # Flag the comment as approved.
+        flag, created = comments.models.CommentFlag.objects.get_or_create(
+            comment = comment,
+            user    = request.user,
+            flag    = comments.models.CommentFlag.MODERATOR_APPROVAL,
+        )
+
+        comment.is_removed = False
+        comment.is_public = True
+        comment.save()
+
+        signals.comment_was_flagged.send(comment)
+        return next_redirect(request.POST.copy(), next, approve_done, c=comment.pk)
+
+    # Render a form on GET
+    else:
+        return render_to_response('comments/approve.html',
+            {'comment': comment, "next": next},
+            template.RequestContext(request)
+        )
+
+approve = permission_required("comments.can_moderate")(approve)
+
+
+#@permission_required("comments.can_moderate")
+def moderation_queue(request):
+    """
+    Displays a list of unapproved comments to be approved.
+
+    Templates: `comments/moderation_queue.html`
+    Context:
+        comments
+            Comments to be approved (paginated).
+        empty
+            Is the comment list empty?
+        is_paginated
+            Is there more than one page?
+        results_per_page
+            Number of comments per page
+        has_next
+            Is there a next page?
+        has_previous
+            Is there a previous page?
+        page
+            The current page number
+        next
+            The next page number
+        pages
+            Number of pages
+        hits
+            Total number of comments
+        page_range
+            Range of page numbers
+
+    """
+    qs = comments.get_model().objects.filter(is_public=False, is_removed=False)
+    paginator = Paginator(qs, 100)
+
+    try:
+        page = int(request.GET.get("page", 1))
+    except ValueError:
+        raise Http404
+
+    try:
+        comments_per_page = paginator.page(page)
+    except InvalidPage:
+        raise Http404
+
+    return render_to_response("comments/moderation_queue.html", {
+        'comments' : comments_per_page.object_list,
+        'empty' : page == 1 and paginator.count == 0,
+        'is_paginated': paginator.num_pages > 1,
+        'results_per_page': 100,
+        'has_next': comments_per_page.has_next(),
+        'has_previous': comments_per_page.has_previous(),
+        'page': page,
+        'next': page + 1,
+        'previous': page - 1,
+        'pages': paginator.num_pages,
+        'hits' : paginator.count,
+        'page_range' : paginator.page_range
+    }, context_instance=template.RequestContext(request))
+
+moderation_queue = permission_required("comments.can_moderate")(moderation_queue)
+
+flag_done = confirmation_view(
+    template = "comments/flagged.html",
+    doc = 'Displays a "comment was flagged" success page.'
+)
+delete_done = confirmation_view(
+    template = "comments/deleted.html",
+    doc = 'Displays a "comment was deleted" success page.'
+)
+approve_done = confirmation_view(
+    template = "comments/approved.html",
+    doc = 'Displays a "comment was approved" success page.'
+)

+ 0 - 62
django/contrib/comments/views/userflags.py

@@ -1,62 +0,0 @@
-from django.shortcuts import render_to_response, get_object_or_404
-from django.template import RequestContext
-from django.http import Http404
-from django.contrib.comments.models import Comment, ModeratorDeletion, UserFlag
-from django.contrib.auth.decorators import login_required
-from django.http import HttpResponseRedirect
-from django.conf import settings
-
-def flag(request, comment_id, extra_context=None, context_processors=None):
-    """
-    Flags a comment. Confirmation on GET, action on POST.
-
-    Templates: `comments/flag_verify`, `comments/flag_done`
-    Context:
-        comment
-            the flagged `comments.comments` object
-    """
-    if extra_context is None: extra_context = {}
-    comment = get_object_or_404(Comment,pk=comment_id, site__id__exact=settings.SITE_ID)
-    if request.POST:
-        UserFlag.objects.flag(comment, request.user)
-        return HttpResponseRedirect('%sdone/' % request.path)
-    return render_to_response('comments/flag_verify.html', {'comment': comment},
-        context_instance=RequestContext(request, extra_context, context_processors))
-flag = login_required(flag)
-
-def flag_done(request, comment_id, extra_context=None, context_processors=None):
-    if extra_context is None: extra_context = {}
-    comment = get_object_or_404(Comment,pk=comment_id, site__id__exact=settings.SITE_ID)
-    return render_to_response('comments/flag_done.html', {'comment': comment},
-        context_instance=RequestContext(request, extra_context, context_processors))
-
-def delete(request, comment_id, extra_context=None, context_processors=None):
-    """
-    Deletes a comment. Confirmation on GET, action on POST.
-
-    Templates: `comments/delete_verify`, `comments/delete_done`
-    Context:
-        comment
-            the flagged `comments.comments` object
-    """
-    if extra_context is None: extra_context = {}
-    comment = get_object_or_404(Comment,pk=comment_id, site__id__exact=settings.SITE_ID)
-    if not Comment.objects.user_is_moderator(request.user):
-        raise Http404
-    if request.POST:
-        # If the comment has already been removed, silently fail.
-        if not comment.is_removed:
-            comment.is_removed = True
-            comment.save()
-            m = ModeratorDeletion(None, request.user.id, comment.id, None)
-            m.save()
-        return HttpResponseRedirect('%sdone/' % request.path)
-    return render_to_response('comments/delete_verify.html', {'comment': comment},
-        context_instance=RequestContext(request, extra_context, context_processors))
-delete = login_required(delete)
-
-def delete_done(request, comment_id, extra_context=None, context_processors=None):
-    if extra_context is None: extra_context = {}
-    comment = get_object_or_404(Comment,pk=comment_id, site__id__exact=settings.SITE_ID)
-    return render_to_response('comments/delete_done.html', {'comment': comment},
-        context_instance=RequestContext(request, extra_context, context_processors))

+ 58 - 0
django/contrib/comments/views/utils.py

@@ -0,0 +1,58 @@
+"""
+A few bits of helper functions for comment views.
+"""
+
+import urllib
+import textwrap
+from django.http import HttpResponseRedirect
+from django.core import urlresolvers
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+from django.core.exceptions import ObjectDoesNotExist
+from django.conf import settings
+from django.contrib import comments
+
+def next_redirect(data, default, default_view, **get_kwargs):
+    """
+    Handle the "where should I go next?" part of comment views.
+
+    The next value could be a kwarg to the function (``default``), or a
+    ``?next=...`` GET arg, or the URL of a given view (``default_view``). See
+    the view modules for examples.
+
+    Returns an ``HttpResponseRedirect``.
+    """
+    next = data.get("next", default)
+    if next is None:
+        next = urlresolvers.reverse(default_view)
+    if get_kwargs:
+        next += "?" + urllib.urlencode(get_kwargs)
+    return HttpResponseRedirect(next)
+
+def confirmation_view(template, doc="Display a confirmation view."):
+    """
+    Confirmation view generator for the "comment was
+    posted/flagged/deleted/approved" views.
+    """
+    def confirmed(request):
+        comment = None
+        if 'c' in request.GET:
+            try:
+                comment = comments.get_model().objects.get(pk=request.GET['c'])
+            except ObjectDoesNotExist:
+                pass
+        return render_to_response(template,
+            {'comment': comment},
+            context_instance=RequestContext(request)
+        )
+
+    confirmed.__doc__ = textwrap.dedent("""\
+        %s
+
+        Templates: `%s``
+        Context:
+            comment
+                The posted comment
+        """ % (help, template)
+    )
+    return confirmed

+ 1 - 1
docs/_static/djangodocs.css

@@ -62,7 +62,7 @@ ins { font-weight: bold; text-decoration: none; }
 /*** lists ***/
 /*** lists ***/
 ul { padding-left:30px; }
 ul { padding-left:30px; }
 ol { padding-left:30px; }
 ol { padding-left:30px; }
-ol.arabic { list-style-type: decimal; }
+ol.arabic li { list-style-type: decimal; }
 ul li { list-style-type:square; margin-bottom:.4em; }
 ul li { list-style-type:square; margin-bottom:.4em; }
 ol li { margin-bottom: .4em; }
 ol li { margin-bottom: .4em; }
 ul ul { padding-left:1.2em; }
 ul ul { padding-left:1.2em; }

+ 35 - 17
docs/index.txt

@@ -72,10 +72,16 @@ Using Django
 And more:
 And more:
 ---------
 ---------
 
 
-:ref:`topics-auth` ... :ref:`topics-cache` ... :ref:`topics-email` ...
-:ref:`topics-files` ... :ref:`topics-i18n` ... :ref:`topics-install` ...
-:ref:`topics-pagination` ... :ref:`topics-serialization` ...
-:ref:`topics-settings` ... :ref:`topics-testing`
+    * :ref:`topics-auth`
+    * :ref:`topics-cache`
+    * :ref:`topics-email`
+    * :ref:`topics-files`
+    * :ref:`topics-i18n`
+    * :ref:`topics-install`
+    * :ref:`topics-pagination`
+    * :ref:`topics-serialization`
+    * :ref:`topics-settings`
+    * :ref:`topics-testing`
     
     
 Add-on ("contrib") applications
 Add-on ("contrib") applications
 ===============================
 ===============================
@@ -95,11 +101,16 @@ Add-on ("contrib") applications
 And more:
 And more:
 ---------
 ---------
 
 
-:ref:`ref-contrib-contenttypes` ... :ref:`ref-contrib-csrf` ...
-:ref:`ref-contrib-databrowse` ... :ref:`ref-contrib-flatpages` ...
-:ref:`ref-contrib-humanize` ... :ref:`ref-contrib-redirects` ...
-:ref:`ref-contrib-sitemaps` ... :ref:`ref-contrib-sites` ...
-:ref:`ref-contrib-webdesign`
+    * :ref:`ref-contrib-comments-index`
+    * :ref:`ref-contrib-contenttypes`
+    * :ref:`ref-contrib-csrf`
+    * :ref:`ref-contrib-databrowse`
+    * :ref:`ref-contrib-flatpages`
+    * :ref:`ref-contrib-humanize`
+    * :ref:`ref-contrib-redirects`
+    * :ref:`ref-contrib-sitemaps`
+    * :ref:`ref-contrib-sites`
+    * :ref:`ref-contrib-webdesign`
 
 
 Solving specific problems
 Solving specific problems
 =========================
 =========================
@@ -120,11 +131,14 @@ Solving specific problems
 And more:
 And more:
 ---------
 ---------
 
 
-:ref:`Authenticating in Apache <howto-apache-auth>` ...
-:ref:`howto-custom-file-storage` ... :ref:`howto-custom-management-commands` ...
-:ref:`howto-custom-model-fields` ... :ref:`howto-error-reporting` ...
-:ref:`howto-initial-data` ... :ref:`howto-static-files`
-    
+    * :ref:`Authenticating in Apache <howto-apache-auth>`
+    * :ref:`howto-custom-file-storage`
+    * :ref:`howto-custom-management-commands`
+    * :ref:`howto-custom-model-fields`
+    * :ref:`howto-error-reporting`
+    * :ref:`howto-initial-data`
+    * :ref:`howto-static-files`
+
 Reference
 Reference
 =========
 =========
 
 
@@ -143,9 +157,13 @@ Reference
 And more:
 And more:
 ---------
 ---------
 
 
-:ref:`ref-databases` ... :ref:`ref-django-admin` ... :ref:`ref-files-index` ...
-:ref:`ref-generic-views` ... :ref:`ref-middleware` ...
-:ref:`ref-templates-index` ... :ref:`ref-unicode`
+    * :ref:`ref-databases`
+    * :ref:`ref-django-admin`
+    * :ref:`ref-files-index`
+    * :ref:`ref-generic-views`
+    * :ref:`ref-middleware`
+    * :ref:`ref-templates-index`
+    * :ref:`ref-unicode`
     
     
 And all the rest
 And all the rest
 ================
 ================

+ 212 - 0
docs/ref/contrib/comments/index.txt

@@ -0,0 +1,212 @@
+.. _ref-contrib-comments-index:
+
+===========================
+Django's comments framework
+===========================
+
+.. module:: django.contrib.comments
+   :synopsis: Django's comment framework
+
+Django includes a simple, yet customizable comments framework. The built-in
+comments framework can be used to attach comments to any model, so you can use
+it for comments on blog entries, photos, book chapters, or anything else.
+
+.. note::
+
+    If you used to use Django's older (undocumented) comments framework, you'll
+    need to upgrade. See the :ref:`upgrade guide <ref-contrib-comments-upgrade>`
+    for instructions.
+
+Quick start guide
+=================
+
+To get started using the ``comments`` app, follow these steps:
+
+    #. Install the comments framework by adding ``'django.contrib.comments'`` to    
+       :setting:`INSTALLED_APPS`.
+
+    #. Run ``manage.py syncdb`` so that Django will create the comment tables.
+
+    #. Add the comment app's URLs to your project's ``urls.py``:
+   
+       .. code-block:: python
+
+            urlpatterns = patterns('',
+                ...
+                (r'^comments/', include('django.contrib.comments.urls')),
+                ...
+            )
+
+    #. Use the `comment template tags`_ below to embed comments in your
+       templates.
+    
+You might also want to examine the :ref:`ref-contrib-comments-settings`
+    
+Comment template tags
+=====================
+
+You'll primarily interact with the comment system through a series of template
+tags that let you embed comments and generate forms for your users to post them.
+
+Like all custom template tag libraries, you'll need to :ref:`load the custom
+tags <loading-custom-template-libraries>` before you can use them::
+
+    {% load comments %}
+
+Once loaded you can use the template tags below.
+
+Specifying which object comments are attached to
+------------------------------------------------
+
+Django's comments are all "attached" to some parent object. This can be any
+instance of a Django model. Each of the tags below gives you a couple of
+different ways you can specify which object to attach to:
+
+    #. Refer to the object directly -- the more common method. Most of the
+       time, you'll have some object in the template's context you want
+       to attach the comment to; you can simply use that object.
+       
+       For example, in a blog entry page that has a variable named ``entry``, 
+       you could use the following to load the number of comments::
+       
+            {% get_comment_count for entry as comment_count %}.
+            
+    #. Refer to the object by content-type and object id. You'd use this method
+       if you, for some reason, don't actually have direct access to the object.
+       
+       Following the above example, if you knew the object ID was ``14`` but
+       didn't have access to the actual object, you could do something like::
+       
+            {% get_comment_count for blog.entry 14 as comment_count %}
+            
+       In the above, ``blog.entry`` is the app label and (lower-cased) model
+       name of the model class.
+
+.. templatetag:: get_comment_list
+
+Displaying comments
+-------------------
+
+To get a the list of comments for some object, use :ttag:`get_comment_list`::
+
+    {% get_comment_list for [object] as [varname] %}
+
+For example::
+
+    {% get_comment_list for event as comment_list %}
+    {% for comment in comment_list %}
+        ...
+    {% endfor %}
+
+.. templatetag:: get_comment_count
+
+Counting comments
+-----------------
+
+To count comments attached to an object, use :ttag:`get_comment_count`::
+
+    {% get_comment_count for [object] as [varname]  %}
+
+For example::
+
+        {% get_comment_count for event as comment_count %}
+        
+        <p>This event has {{ comment_count }} comments.</p>
+        
+
+Displaying the comment post form
+--------------------------------
+
+To show the form that users will use to post a comment, you can use
+:ttag:`render_comment_form` or :ttag:`get_comment_form`
+
+.. templatetag:: render_comment_form
+
+Quickly rendering the comment form
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The easiest way to display a comment form is by using
+:ttag:`render_comment_form`::
+
+    {% render_comment_form for [object] %}
+
+For example::
+
+    {% render_comment_form for event %}
+
+This will render comments using a template named ``comments/form.html``, a
+default version of which is included with Django.
+
+.. templatetag:: get_comment_form
+
+Rendering a custom comment form
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you want more control over the look and feel of the comment form, you use use
+:ttag:`get_comment_form` to get a :ref:`form object <topics-forms-index>` that
+you can use in the template::
+
+    {% get_comment_form for [object] %}
+    
+A complete form might look like::
+
+    {% get_comment_form for event %}
+    <form action="{% comment_form_target %}" method="POST">
+      {{ form }}
+      <p class="submit">
+        <input type="submit" name="submit" class="submit-post" value="Preview">
+      </p>
+    </form>
+    
+Be sure to read the `notes on the comment form`_, below, for some special
+considerations you'll need to make if you're using this aproach.
+
+.. templatetag:: comment_form_target
+
+Getting the comment form target
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+You may have noticed that the above example uses another template tag --
+:ttag:`comment_form_target` -- to actually get the ``action`` attribute of the
+form. This will always return the correct URL that comments should be posted to;
+you'll always want to use it like above::
+
+    <form action="{% comment_form_target %}" method="POST">
+
+Notes on the comment form
+-------------------------
+
+The form used by the comment system has a few important anti-spam attributes you
+should know about:
+
+    * It contains a number of hidden fields that contain timestamps, information
+      about the object the comment should be attached to, and a "security hash"
+      used to validate this information. If someone tampers with this data -- 
+      something comment spammers will try -- the comment submission will fail.
+      
+      If you're rendering a custom comment form, you'll need to make sure to
+      pass these values through unchanged.
+      
+    * The timestamp is used to ensure that "reply attacks" can't continue very
+      long. Users who wait too long between requesting the form and posting a
+      comment will have their submissions refused.
+      
+    * The comment form includes a "honeypot_" field. It's a trap: if any data is
+      entered in that field, the comment will be considered spam (spammers often
+      automatically fill in all fields in an attempt to make valid submissions).
+      
+      The default form hides this field with a piece of CSS and further labels
+      it with a warning field; if you use the comment form with a custom
+      template you should be sure to do the same.
+    
+.. _honeypot: http://en.wikipedia.org/wiki/Honeypot_(computing)
+
+More information
+================
+
+.. toctree::
+   :maxdepth: 1
+
+   settings
+   upgrade
+

+ 34 - 0
docs/ref/contrib/comments/settings.txt

@@ -0,0 +1,34 @@
+.. _ref-contrib-comments-settings:
+
+================
+Comment settings
+================
+
+These settings configure the behavior of the comments framework:
+
+.. setting:: COMMENTS_HIDE_REMOVED
+
+COMMENTS_HIDE_REMOVED
+---------------------
+
+If ``True`` (default), removed comments will be excluded from comment
+lists/counts (as taken from template tags). Otherwise, the template author is
+responsible for some sort of a "this comment has been removed by the site staff"
+message.
+
+.. setting:: COMMENT_MAX_LENGTH
+
+COMMENT_MAX_LENGTH
+------------------
+
+The maximum length of the comment field, in characters. Comments longer than
+this will be rejected. Defaults to 3000.
+
+.. setting:: COMENTS_APP
+
+COMENTS_APP
+-----------
+
+The app (i.e. entry in ``INSTALLED_APPS``) responsible for all "business logic."
+You can change this to provide custom comment models and forms, though this is
+currently undocumented.

+ 63 - 0
docs/ref/contrib/comments/upgrade.txt

@@ -0,0 +1,63 @@
+.. _ref-contrib-comments-upgrade:
+
+===============================================
+Upgrading from Django's previous comment system
+===============================================
+
+Prior versions of Django included an outdated, undocumented comment system. Users who reverse-engineered this framework will need to upgrade to use the
+new comment system; this guide explains how.
+
+The main changes from the old system are:
+
+    * This new system is documented.
+    
+    * It uses modern Django features like :ref:`forms <topics-forms-index>` and
+      :ref:`modelforms <topics-forms-modelforms>`.
+
+    * It has a single ``Comment`` model instead of separate ``FreeComment`` and
+      ``Comment`` models.
+
+    * Comments have "email" and "URL" fields.
+
+    * No ratings, photos and karma. This should only effect World Online.
+
+    * The ``{% comment_form %}`` tag no longer exists. Instead, there's now two
+      functions: ``{% get_comment_form %}``, which returns a form for posting a
+      new comment, and ``{% render_comment_form %}``, which renders said form
+      using the ``comments/form.html`` template.
+
+Upgrading data
+--------------
+
+The data models have changed, as have the table names. To transfer your data into the new system, you'll need to directly run the following SQL:
+
+.. code-block:: sql
+
+    BEGIN;
+
+    INSERT INTO django_comments 
+        (content_type_id, object_pk, site_id, user_name, user_email, user_url,
+        comment, submit_date, ip_address, is_public, is_removed)
+    SELECT
+        content_type_id, object_id, site_id, person_name, '', '', comment,
+        submit_date, ip_address, is_public, approved
+    FROM comments_freecomment;
+
+    INSERT INTO django_comments 
+        (content_type_id, object_pk, site_id, user_id, comment, submit_date,
+        ip_address, is_public, is_removed) 
+    SELECT 
+        content_type_id, object_id, site_id, user_id, comment, submit_date,
+        ip_address, is_public, is_removed
+    FROM comments_comment;
+
+    UPDATE django_comments SET user_name = (
+        SELECT username FROM auth_user 
+        WHERE django_comments.user_id = auth_user.id
+    );
+    UPDATE django_comments SET user_email = (
+        SELECT email FROM auth_user 
+        WHERE django_comments.user_id = auth_user.id
+    );
+    
+    COMMIT;

+ 4 - 1
docs/ref/contrib/index.txt

@@ -26,6 +26,7 @@ those packages have.
 
 
    admin
    admin
    auth
    auth
+   comments/index
    contenttypes
    contenttypes
    csrf
    csrf
    databrowse
    databrowse
@@ -58,7 +59,9 @@ See :ref:`topics-auth`.
 comments
 comments
 ========
 ========
 
 
-A simple yet flexible comments system. This is not yet documented.
+**New in Django development version.**
+
+A simple yet flexible comments system. See :ref:`ref-contrib-comments-index`.
 
 
 contenttypes
 contenttypes
 ============
 ============

+ 2 - 0
docs/topics/templates.txt

@@ -607,6 +607,8 @@ along with all the fields available on that object.
 Taken together, the documentation pages should tell you every tag, filter,
 Taken together, the documentation pages should tell you every tag, filter,
 variable and object available to you in a given template.
 variable and object available to you in a given template.
 
 
+.. _loading-custom-template-libraries:
+
 Custom tag and filter libraries
 Custom tag and filter libraries
 ===============================
 ===============================
 
 

+ 0 - 0
django/contrib/comments/urls/__init__.py → tests/regressiontests/comment_tests/__init__.py


+ 43 - 0
tests/regressiontests/comment_tests/fixtures/comment_tests.json

@@ -0,0 +1,43 @@
+[
+  {
+    "model" : "comment_tests.author",
+    "pk" : 1,
+    "fields" : {
+        "first_name" : "John",
+        "last_name" : "Smith"
+    }
+  },
+  {
+    "model" : "comment_tests.author",
+    "pk" : 2,
+    "fields" : {
+        "first_name" : "Peter",
+        "last_name" : "Jones"
+    }
+  },
+  {
+    "model" : "comment_tests.article",
+    "pk" : 1,
+    "fields" : {
+        "author" : 1,
+        "headline" : "Man Bites Dog"
+    }
+  },
+  {
+    "model" : "comment_tests.article",
+    "pk" : 2,
+    "fields" : {
+        "author" : 2,
+        "headline" : "Dog Bites Man"
+    }
+  },
+
+  {
+    "model" : "auth.user",
+    "pk" : 100,
+    "fields" : {
+        "username" : "normaluser",
+        "password" : "34ea4aaaf24efcbb4b30d27302f8657f"
+    }
+  }
+]

+ 22 - 0
tests/regressiontests/comment_tests/models.py

@@ -0,0 +1,22 @@
+"""
+Comments may be attached to any object. See the comment documentation for
+more information.
+"""
+
+from django.db import models
+from django.test import TestCase
+
+class Author(models.Model):
+    first_name = models.CharField(max_length=30)
+    last_name = models.CharField(max_length=30)
+
+    def __str__(self):
+        return '%s %s' % (self.first_name, self.last_name)
+
+class Article(models.Model):
+    author = models.ForeignKey(Author)
+    headline = models.CharField(max_length=100)
+
+    def __str__(self):
+        return self.headline
+

+ 90 - 0
tests/regressiontests/comment_tests/tests/__init__.py

@@ -0,0 +1,90 @@
+from django.contrib.auth.models import User
+from django.contrib.comments.forms import CommentForm
+from django.contrib.comments.models import Comment
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.sites.models import Site
+from django.test import TestCase
+from regressiontests.comment_tests.models import Article, Author
+
+# Shortcut
+CT = ContentType.objects.get_for_model
+
+# Helper base class for comment tests that need data.
+class CommentTestCase(TestCase):
+    fixtures = ["comment_tests"]
+
+    def setUp(self):
+        settings.ROOT_URLCONF = "django.contrib.comments.urls"
+
+    def createSomeComments(self):
+        # Two anonymous comments on two different objects
+        c1 = Comment.objects.create(
+            content_type = CT(Article),
+            object_pk = "1",
+            user_name = "Joe Somebody",
+            user_email = "jsomebody@example.com",
+            user_url = "http://example.com/~joe/",
+            comment = "First!",
+            site = Site.objects.get_current(),
+        )
+        c2 = Comment.objects.create(
+            content_type = CT(Author),
+            object_pk = "1",
+            user_name = "Joe Somebody",
+            user_email = "jsomebody@example.com",
+            user_url = "http://example.com/~joe/",
+            comment = "First here, too!",
+            site = Site.objects.get_current(),
+        )
+
+        # Two authenticated comments: one on the same Article, and
+        # one on a different Author
+        user = User.objects.create(
+            username = "frank_nobody",
+            first_name = "Frank",
+            last_name = "Nobody",
+            email = "fnobody@example.com",
+            password = "",
+            is_staff = False,
+            is_active = True,
+            is_superuser = False,
+        )
+        c3 = Comment.objects.create(
+            content_type = CT(Article),
+            object_pk = "1",
+            user = user,
+            user_url = "http://example.com/~frank/",
+            comment = "Damn, I wanted to be first.",
+            site = Site.objects.get_current(),
+        )
+        c4 = Comment.objects.create(
+            content_type = CT(Author),
+            object_pk = "2",
+            user = user,
+            user_url = "http://example.com/~frank/",
+            comment = "You get here first, too?",
+            site = Site.objects.get_current(),
+        )
+
+        return c1, c2, c3, c4
+
+    def getData(self):
+        return {
+            'name'      : 'Jim Bob',
+            'email'     : 'jim.bob@example.com',
+            'url'       : '',
+            'comment'   : 'This is my comment',
+        }
+
+    def getValidData(self, obj):
+        f = CommentForm(obj)
+        d = self.getData()
+        d.update(f.initial)
+        return d
+
+from regressiontests.comment_tests.tests.app_api_tests import *
+from regressiontests.comment_tests.tests.model_tests import *
+from regressiontests.comment_tests.tests.comment_form_tests import *
+from regressiontests.comment_tests.tests.templatetag_tests import *
+from regressiontests.comment_tests.tests.comment_view_tests import *
+from regressiontests.comment_tests.tests.moderation_view_tests import *

+ 30 - 0
tests/regressiontests/comment_tests/tests/app_api_tests.py

@@ -0,0 +1,30 @@
+from django.conf import settings
+from django.contrib import comments
+from django.contrib.comments.models import Comment
+from django.contrib.comments.forms import CommentForm
+from regressiontests.comment_tests.tests import CommentTestCase
+
+class CommentAppAPITests(CommentTestCase):
+    """Tests for the "comment app" API"""
+
+    def testGetCommentApp(self):
+        self.assertEqual(comments.get_comment_app(), comments)
+
+    def testGetForm(self):
+        self.assertEqual(comments.get_form(), CommentForm)
+
+    def testGetFormTarget(self):
+        self.assertEqual(comments.get_form_target(), "/post/")
+
+    def testGetFlagURL(self):
+        c = Comment(id=12345)
+        self.assertEqual(comments.get_flag_url(c), "/flag/12345/")
+
+    def getGetDeleteURL(self):
+        c = Comment(id=12345)
+        self.assertEqual(comments.get_delete_url(c), "/delete/12345/")
+
+    def getGetApproveURL(self):
+        c = Comment(id=12345)
+        self.assertEqual(comments.get_approve_url(c), "/approve/12345/")
+

+ 81 - 0
tests/regressiontests/comment_tests/tests/comment_form_tests.py

@@ -0,0 +1,81 @@
+import time
+from django.conf import settings
+from django.contrib.comments.models import Comment
+from django.contrib.comments.forms import CommentForm
+from regressiontests.comment_tests.models import Article
+from regressiontests.comment_tests.tests import CommentTestCase
+
+class CommentFormTests(CommentTestCase):
+
+    def testInit(self):
+        f = CommentForm(Article.objects.get(pk=1))
+        self.assertEqual(f.initial['content_type'], str(Article._meta))
+        self.assertEqual(f.initial['object_pk'], "1")
+        self.failIfEqual(f.initial['security_hash'], None)
+        self.failIfEqual(f.initial['timestamp'], None)
+
+    def testValidPost(self):
+        a = Article.objects.get(pk=1)
+        f = CommentForm(a, data=self.getValidData(a))
+        self.assert_(f.is_valid(), f.errors)
+        return f
+
+    def tamperWithForm(self, **kwargs):
+        a = Article.objects.get(pk=1)
+        d = self.getValidData(a)
+        d.update(kwargs)
+        f = CommentForm(Article.objects.get(pk=1), data=d)
+        self.failIf(f.is_valid())
+        return f
+
+    def testHoneypotTampering(self):
+        self.tamperWithForm(honeypot="I am a robot")
+
+    def testTimestampTampering(self):
+        self.tamperWithForm(timestamp=str(time.time() - 28800))
+
+    def testSecurityHashTampering(self):
+        self.tamperWithForm(security_hash="Nobody expects the Spanish Inquisition!")
+
+    def testContentTypeTampering(self):
+        self.tamperWithForm(content_type="auth.user")
+
+    def testObjectPKTampering(self):
+        self.tamperWithForm(object_pk="3")
+
+    def testSecurityErrors(self):
+        f = self.tamperWithForm(honeypot="I am a robot")
+        self.assert_("honeypot" in f.security_errors())
+
+    def testGetCommentObject(self):
+        f = self.testValidPost()
+        c = f.get_comment_object()
+        self.assert_(isinstance(c, Comment))
+        self.assertEqual(c.content_object, Article.objects.get(pk=1))
+        self.assertEqual(c.comment, "This is my comment")
+        c.save()
+        self.assertEqual(Comment.objects.count(), 1)
+
+    def testProfanities(self):
+        """Test COMMENTS_ALLOW_PROFANITIES and PROFANITIES_LIST settings"""
+        a = Article.objects.get(pk=1)
+        d = self.getValidData(a)
+
+        # Save settings in case other tests need 'em
+        saved = settings.PROFANITIES_LIST, settings.COMMENTS_ALLOW_PROFANITIES
+
+        # Don't wanna swear in the unit tests if we don't have to...
+        settings.PROFANITIES_LIST = ["rooster"]
+
+        # Try with COMMENTS_ALLOW_PROFANITIES off
+        settings.COMMENTS_ALLOW_PROFANITIES = False
+        f = CommentForm(a, data=dict(d, comment="What a rooster!"))
+        self.failIf(f.is_valid())
+
+        # Now with COMMENTS_ALLOW_PROFANITIES on
+        settings.COMMENTS_ALLOW_PROFANITIES = True
+        f = CommentForm(a, data=dict(d, comment="What a rooster!"))
+        self.failUnless(f.is_valid())
+
+        # Restore settings
+        settings.PROFANITIES_LIST, settings.COMMENTS_ALLOW_PROFANITIES = saved

+ 166 - 0
tests/regressiontests/comment_tests/tests/comment_view_tests.py

@@ -0,0 +1,166 @@
+from django.conf import settings
+from django.contrib.comments import signals
+from django.contrib.comments.models import Comment
+from regressiontests.comment_tests.models import Article
+from regressiontests.comment_tests.tests import CommentTestCase
+
+class CommentViewTests(CommentTestCase):
+
+    def testPostCommentHTTPMethods(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        response = self.client.get("/post/", data)
+        self.assertEqual(response.status_code, 405)
+        self.assertEqual(response["Allow"], "POST")
+
+    def testPostCommentMissingCtype(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        del data["content_type"]
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+
+    def testPostCommentBadCtype(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        data["content_type"] = "Nobody expects the Spanish Inquisition!"
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+
+    def testPostCommentMissingObjectPK(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        del data["object_pk"]
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+
+    def testPostCommentBadObjectPK(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        data["object_pk"] = "14"
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+
+    def testCommentPreview(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        data["submit"] = "preview"
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "comments/preview.html")
+
+    def testHashTampering(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        data["security_hash"] = "Nobody expects the Spanish Inquisition!"
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+
+    def testDebugCommentErrors(self):
+        """The debug error template should be shown only if DEBUG is True"""
+        olddebug = settings.DEBUG
+
+        settings.DEBUG = True
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        data["security_hash"] = "Nobody expects the Spanish Inquisition!"
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+        self.assertTemplateUsed(response, "comments/400-debug.html")
+
+        settings.DEBUG = False
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+        self.assertTemplateNotUsed(response, "comments/400-debug.html")
+
+        settings.DEBUG = olddebug
+
+    def testCreateValidComment(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        self.response = self.client.post("/post/", data, REMOTE_ADDR="1.2.3.4")
+        self.assertEqual(self.response.status_code, 302)
+        self.assertEqual(Comment.objects.count(), 1)
+        c = Comment.objects.all()[0]
+        self.assertEqual(c.ip_address, "1.2.3.4")
+        self.assertEqual(c.comment, "This is my comment")
+
+    def testPreventDuplicateComments(self):
+        """Prevent posting the exact same comment twice"""
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        self.client.post("/post/", data)
+        self.client.post("/post/", data)
+        self.assertEqual(Comment.objects.count(), 1)
+
+        # This should not trigger the duplicate prevention
+        self.client.post("/post/", dict(data, comment="My second comment."))
+        self.assertEqual(Comment.objects.count(), 2)
+
+    def testCommentSignals(self):
+        """Test signals emitted by the comment posting view"""
+
+        # callback
+        def receive(sender, **kwargs):
+            self.assertEqual(sender.comment, "This is my comment")
+            # TODO: Get the two commented tests below to work.
+#            self.assertEqual(form_data["comment"], "This is my comment")
+#            self.assertEqual(request.method, "POST")
+            received_signals.append(kwargs.get('signal'))
+
+        # Connect signals and keep track of handled ones
+        received_signals = []
+        excepted_signals = [signals.comment_will_be_posted, signals.comment_was_posted]
+        for signal in excepted_signals:
+            signal.connect(receive)
+
+        # Post a comment and check the signals
+        self.testCreateValidComment()
+        self.assertEqual(received_signals, excepted_signals)
+
+    def testWillBePostedSignal(self):
+        """
+        Test that the comment_will_be_posted signal can prevent the comment from
+        actually getting saved
+        """
+        def receive(sender, **kwargs): return False
+        signals.comment_will_be_posted.connect(receive)
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        response = self.client.post("/post/", data)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(Comment.objects.count(), 0)
+
+    def testWillBePostedSignalModifyComment(self):
+        """
+        Test that the comment_will_be_posted signal can modify a comment before
+        it gets posted
+        """
+        def receive(sender, **kwargs):
+            sender.is_public = False # a bad but effective spam filter :)...
+
+        signals.comment_will_be_posted.connect(receive)
+        self.testCreateValidComment()
+        c = Comment.objects.all()[0]
+        self.failIf(c.is_public)
+
+    def testCommentNext(self):
+        """Test the different "next" actions the comment view can take"""
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        response = self.client.post("/post/", data)
+        self.assertEqual(response["Location"], "http://testserver/posted/?c=1")
+
+        data["next"] = "/somewhere/else/"
+        data["comment"] = "This is another comment"
+        response = self.client.post("/post/", data)
+        self.assertEqual(response["Location"], "http://testserver/somewhere/else/?c=2")
+
+    def testCommentDoneView(self):
+        a = Article.objects.get(pk=1)
+        data = self.getValidData(a)
+        response = self.client.post("/post/", data)
+        response = self.client.get("/posted/", {'c':1})
+        self.assertTemplateUsed(response, "comments/posted.html")
+        self.assertEqual(response.context[0]["comment"], Comment.objects.get(pk=1))
+

+ 48 - 0
tests/regressiontests/comment_tests/tests/model_tests.py

@@ -0,0 +1,48 @@
+from django.contrib.comments.models import Comment
+from regressiontests.comment_tests.models import Author, Article
+from regressiontests.comment_tests.tests import CommentTestCase
+
+class CommentModelTests(CommentTestCase):
+
+    def testSave(self):
+        for c in self.createSomeComments():
+            self.failIfEqual(c.submit_date, None)
+
+    def testUserProperties(self):
+        c1, c2, c3, c4 = self.createSomeComments()
+        self.assertEqual(c1.name, "Joe Somebody")
+        self.assertEqual(c2.email, "jsomebody@example.com")
+        self.assertEqual(c3.name, "Frank Nobody")
+        self.assertEqual(c3.url, "http://example.com/~frank/")
+        self.assertEqual(c1.user, None)
+        self.assertEqual(c3.user, c4.user)
+
+class CommentManagerTests(CommentTestCase):
+
+    def testInModeration(self):
+        """Comments that aren't public are considered in moderation"""
+        c1, c2, c3, c4 = self.createSomeComments()
+        c1.is_public = False
+        c2.is_public = False
+        c1.save()
+        c2.save()
+        moderated_comments = list(Comment.objects.in_moderation().order_by("id"))
+        self.assertEqual(moderated_comments, [c1, c2])
+
+    def testRemovedCommentsNotInModeration(self):
+        """Removed comments are not considered in moderation"""
+        c1, c2, c3, c4 = self.createSomeComments()
+        c1.is_public = False
+        c2.is_public = False
+        c2.is_removed = True
+        c1.save()
+        c2.save()
+        moderated_comments = list(Comment.objects.in_moderation())
+        self.assertEqual(moderated_comments, [c1])
+
+    def testForModel(self):
+        c1, c2, c3, c4 = self.createSomeComments()
+        article_comments = list(Comment.objects.for_model(Article).order_by("id"))
+        author_comments = list(Comment.objects.for_model(Author.objects.get(pk=1)))
+        self.assertEqual(article_comments, [c1, c3])
+        self.assertEqual(author_comments, [c2])

+ 181 - 0
tests/regressiontests/comment_tests/tests/moderation_view_tests.py

@@ -0,0 +1,181 @@
+from django.contrib.comments.models import Comment, CommentFlag
+from django.contrib.auth.models import User, Permission
+from django.contrib.contenttypes.models import ContentType
+from regressiontests.comment_tests.tests import CommentTestCase
+from django.contrib.comments import signals
+
+class FlagViewTests(CommentTestCase):
+
+    def testFlagGet(self):
+        """GET the flag view: render a confirmation page."""
+        self.createSomeComments()
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.get("/flag/1/")
+        self.assertTemplateUsed(response, "comments/flag.html")
+
+    def testFlagPost(self):
+        """POST the flag view: actually flag the view (nice for XHR)"""
+        self.createSomeComments()
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/flag/1/")
+        self.assertEqual(response["Location"], "http://testserver/flagged/?c=1")
+        c = Comment.objects.get(pk=1)
+        self.assertEqual(c.flags.filter(flag=CommentFlag.SUGGEST_REMOVAL).count(), 1)
+        return c
+
+    def testFlagPostTwice(self):
+        """Users don't get to flag comments more than once."""
+        c = self.testFlagPost()
+        self.client.post("/flag/1/")
+        self.client.post("/flag/1/")
+        self.assertEqual(c.flags.filter(flag=CommentFlag.SUGGEST_REMOVAL).count(), 1)
+
+    def testFlagAnon(self):
+        """GET/POST the flag view while not logged in: redirect to log in."""
+        self.createSomeComments()
+        response = self.client.get("/flag/1/")
+        self.assertEqual(response["Location"], "http://testserver/accounts/login/?next=/flag/1/")
+        response = self.client.post("/flag/1/")
+        self.assertEqual(response["Location"], "http://testserver/accounts/login/?next=/flag/1/")
+
+    def testFlaggedView(self):
+        self.createSomeComments()
+        response = self.client.get("/flagged/", data={"c":1})
+        self.assertTemplateUsed(response, "comments/flagged.html")
+
+    def testFlagSignals(self):
+        """Test signals emitted by the comment flag view"""
+
+        # callback
+        def receive(sender, **kwargs):
+            flag = sender.flags.get(id=1)
+            self.assertEqual(flag.flag, CommentFlag.SUGGEST_REMOVAL)
+            self.assertEqual(flag.user.username, "normaluser")
+            received_signals.append(kwargs.get('signal'))
+
+        # Connect signals and keep track of handled ones
+        received_signals = []
+        signals.comment_was_flagged.connect(receive)
+
+        # Post a comment and check the signals
+        self.testFlagPost()
+        self.assertEqual(received_signals, [signals.comment_was_flagged])
+
+def makeModerator(username):
+    u = User.objects.get(username=username)
+    ct = ContentType.objects.get_for_model(Comment)
+    p = Permission.objects.get(content_type=ct, codename="can_moderate")
+    u.user_permissions.add(p)
+
+class DeleteViewTests(CommentTestCase):
+
+    def testDeletePermissions(self):
+        """The delete view should only be accessible to 'moderators'"""
+        self.createSomeComments()
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.get("/delete/1/")
+        self.assertEqual(response["Location"], "http://testserver/accounts/login/?next=/delete/1/")
+
+        makeModerator("normaluser")
+        response = self.client.get("/delete/1/")
+        self.assertEqual(response.status_code, 200)
+
+    def testDeletePost(self):
+        """POSTing the delete view should mark the comment as removed"""
+        self.createSomeComments()
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/delete/1/")
+        self.assertEqual(response["Location"], "http://testserver/deleted/?c=1")
+        c = Comment.objects.get(pk=1)
+        self.failUnless(c.is_removed)
+        self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_DELETION, user__username="normaluser").count(), 1)
+
+    def testDeleteSignals(self):
+        def receive(sender, **kwargs):
+            received_signals.append(kwargs.get('signal'))
+
+        # Connect signals and keep track of handled ones
+        received_signals = []
+        signals.comment_was_flagged.connect(receive)
+
+        # Post a comment and check the signals
+        self.testDeletePost()
+        self.assertEqual(received_signals, [signals.comment_was_flagged])
+
+    def testDeletedView(self):
+        self.createSomeComments()
+        response = self.client.get("/deleted/", data={"c":1})
+        self.assertTemplateUsed(response, "comments/deleted.html")
+
+class ApproveViewTests(CommentTestCase):
+
+    def testApprovePermissions(self):
+        """The delete view should only be accessible to 'moderators'"""
+        self.createSomeComments()
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.get("/approve/1/")
+        self.assertEqual(response["Location"], "http://testserver/accounts/login/?next=/approve/1/")
+
+        makeModerator("normaluser")
+        response = self.client.get("/approve/1/")
+        self.assertEqual(response.status_code, 200)
+
+    def testApprovePost(self):
+        """POSTing the delete view should mark the comment as removed"""
+        c1, c2, c3, c4 = self.createSomeComments()
+        c1.is_public = False; c1.save()
+
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/approve/1/")
+        self.assertEqual(response["Location"], "http://testserver/approved/?c=1")
+        c = Comment.objects.get(pk=1)
+        self.failUnless(c.is_public)
+        self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_APPROVAL, user__username="normaluser").count(), 1)
+
+    def testApproveSignals(self):
+        def receive(sender, **kwargs):
+            received_signals.append(kwargs.get('signal'))
+
+        # Connect signals and keep track of handled ones
+        received_signals = []
+        signals.comment_was_flagged.connect(receive)
+
+        # Post a comment and check the signals
+        self.testApprovePost()
+        self.assertEqual(received_signals, [signals.comment_was_flagged])
+
+    def testApprovedView(self):
+        self.createSomeComments()
+        response = self.client.get("/approved/", data={"c":1})
+        self.assertTemplateUsed(response, "comments/approved.html")
+
+
+class ModerationQueueTests(CommentTestCase):
+
+    def testModerationQueuePermissions(self):
+        """Only moderators can view the moderation queue"""
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.get("/moderate/")
+        self.assertEqual(response["Location"], "http://testserver/accounts/login/?next=/moderate/")
+
+        makeModerator("normaluser")
+        response = self.client.get("/moderate/")
+        self.assertEqual(response.status_code, 200)
+
+    def testModerationQueueContents(self):
+        """Moderation queue should display non-public, non-removed comments."""
+        c1, c2, c3, c4 = self.createSomeComments()
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+
+        c1.is_public = c2.is_public = False
+        c1.save(); c2.save()
+        response = self.client.get("/moderate/")
+        self.assertEqual(list(response.context[0]["comments"]), [c1, c2])
+
+        c2.is_removed = True
+        c2.save()
+        response = self.client.get("/moderate/")
+        self.assertEqual(list(response.context[0]["comments"]), [c1])

+ 65 - 0
tests/regressiontests/comment_tests/tests/templatetag_tests.py

@@ -0,0 +1,65 @@
+from django.contrib.comments.forms import CommentForm
+from django.contrib.comments.models import Comment
+from django.template import Template, Context
+from regressiontests.comment_tests.models import Article, Author
+from regressiontests.comment_tests.tests import CommentTestCase
+
+class CommentTemplateTagTests(CommentTestCase):
+
+    def render(self, t, **c):
+        ctx = Context(c)
+        out = Template(t).render(ctx)
+        return ctx, out
+
+    def testCommentFormTarget(self):
+        ctx, out = self.render("{% load comments %}{% comment_form_target %}")
+        self.assertEqual(out, "/post/")
+
+    def testGetCommentForm(self, tag=None):
+        t = "{% load comments %}" + (tag or "{% get_comment_form for comment_tests.article a.id as form %}")
+        ctx, out = self.render(t, a=Article.objects.get(pk=1))
+        self.assertEqual(out, "")
+        self.assert_(isinstance(ctx["form"], CommentForm))
+
+    def testGetCommentFormFromLiteral(self):
+        self.testGetCommentForm("{% get_comment_form for comment_tests.article 1 as form %}")
+
+    def testGetCommentFormFromObject(self):
+        self.testGetCommentForm("{% get_comment_form for a as form %}")
+
+    def testRenderCommentForm(self, tag=None):
+        t = "{% load comments %}" + (tag or "{% render_comment_form for comment_tests.article a.id %}")
+        ctx, out = self.render(t, a=Article.objects.get(pk=1))
+        self.assert_(out.strip().startswith("<form action="))
+        self.assert_(out.strip().endswith("</form>"))
+
+    def testRenderCommentFormFromLiteral(self):
+        self.testRenderCommentForm("{% render_comment_form for comment_tests.article 1 %}")
+
+    def testRenderCommentFormFromObject(self):
+        self.testRenderCommentForm("{% render_comment_form for a %}")
+
+    def testGetCommentCount(self, tag=None):
+        self.createSomeComments()
+        t = "{% load comments %}" + (tag or "{% get_comment_count for comment_tests.article a.id as cc %}") + "{{ cc }}"
+        ctx, out = self.render(t, a=Article.objects.get(pk=1))
+        self.assertEqual(out, "2")
+
+    def testGetCommentCountFromLiteral(self):
+        self.testGetCommentCount("{% get_comment_count for comment_tests.article 1 as cc %}")
+
+    def testGetCommentCountFromObject(self):
+        self.testGetCommentCount("{% get_comment_count for a as cc %}")
+
+    def testGetCommentList(self, tag=None):
+        c1, c2, c3, c4 = self.createSomeComments()
+        t = "{% load comments %}" + (tag or "{% get_comment_list for comment_tests.author a.id as cl %}")
+        ctx, out = self.render(t, a=Author.objects.get(pk=1))
+        self.assertEqual(out, "")
+        self.assertEqual(list(ctx["cl"]), [c2])
+
+    def testGetCommentListFromLiteral(self):
+        self.testGetCommentList("{% get_comment_list for comment_tests.author 1 as cl %}")
+
+    def testGetCommentListFromObject(self):
+        self.testGetCommentList("{% get_comment_list for a as cl %}")