diff --git a/AUTHORS b/AUTHORS index 7d8277fd12..5b8dfab767 100644 --- a/AUTHORS +++ b/AUTHORS @@ -322,6 +322,7 @@ answer newbie questions, and generally made Django that much better: polpak@yahoo.com Matthias Pronk Jyrki Pulliainen + Thejaswi Puthraya Johann Queuniet Jan Rademaker Michael Radziej diff --git a/django/contrib/comments/__init__.py b/django/contrib/comments/__init__.py index e69de29bb2..2455d40e92 100644 --- a/django/contrib/comments/__init__.py +++ b/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,)) diff --git a/django/contrib/comments/admin.py b/django/contrib/comments/admin.py index 81ecc699c7..010e9b0d9f 100644 --- a/django/contrib/comments/admin.py +++ b/django/contrib/comments/admin.py @@ -1,30 +1,24 @@ from django.contrib import admin -from django.contrib.comments.models import Comment, FreeComment +from django.conf import settings +from django.contrib.comments.models import Comment +from django.utils.translation import ugettext_lazy as _ - -class CommentAdmin(admin.ModelAdmin): +class CommentsAdmin(admin.ModelAdmin): fieldsets = ( - (None, {'fields': ('content_type', 'object_id', 'site')}), - ('Content', {'fields': ('user', 'headline', 'comment')}), - ('Ratings', {'fields': ('rating1', 'rating2', 'rating3', 'rating4', 'rating5', 'rating6', 'rating7', 'rating8', 'valid_rating')}), - ('Meta', {'fields': ('is_public', 'is_removed', 'ip_address')}), - ) - list_display = ('user', 'submit_date', 'content_type', 'get_content_object') - list_filter = ('submit_date',) - date_hierarchy = 'submit_date' - search_fields = ('comment', 'user__username') - raw_id_fields = ('user',) + (None, + {'fields': ('content_type', 'object_pk', 'site')} + ), + (_('Content'), + {'fields': ('user', 'user_name', 'user_email', 'user_url', 'comment')} + ), + (_('Metadata'), + {'fields': ('submit_date', 'ip_address', 'is_public', 'is_removed')} + ), + ) -class FreeCommentAdmin(admin.ModelAdmin): - fieldsets = ( - (None, {'fields': ('content_type', 'object_id', 'site')}), - ('Content', {'fields': ('person_name', 'comment')}), - ('Meta', {'fields': ('is_public', 'ip_address', 'approved')}), - ) - list_display = ('person_name', 'submit_date', 'content_type', 'get_content_object') - list_filter = ('submit_date',) + list_display = ('name', 'content_type', 'object_pk', 'ip_address', 'is_public', 'is_removed') + list_filter = ('submit_date', 'site', 'is_public', 'is_removed') date_hierarchy = 'submit_date' - search_fields = ('comment', 'person_name') + search_fields = ('comment', 'user__username', 'user_name', 'user_email', 'user_url', 'ip_address') -admin.site.register(Comment, CommentAdmin) -admin.site.register(FreeComment, FreeCommentAdmin) \ No newline at end of file +admin.site.register(Comment, CommentsAdmin) diff --git a/django/contrib/comments/feeds.py b/django/contrib/comments/feeds.py index 901254f3c4..232fe290e0 100644 --- a/django/contrib/comments/feeds.py +++ b/django/contrib/comments/feeds.py @@ -1,12 +1,10 @@ from django.conf import settings -from django.contrib.comments.models import Comment, FreeComment from django.contrib.syndication.feeds import Feed from django.contrib.sites.models import Site +from django.contrib import comments -class LatestFreeCommentsFeed(Feed): - """Feed of latest free comments on the current site.""" - - comments_class = FreeComment +class LatestCommentFeed(Feed): + """Feed of latest comments on the current site.""" def title(self): if not hasattr(self, '_site'): @@ -23,22 +21,17 @@ class LatestFreeCommentsFeed(Feed): self._site = Site.objects.get_current() return u"Latest comments on %s" % self._site.name - def get_query_set(self): - return self.comments_class.objects.filter(site__pk=settings.SITE_ID, is_public=True) - def items(self): - return self.get_query_set()[:40] - -class LatestCommentsFeed(LatestFreeCommentsFeed): - """Feed of latest comments on the current site.""" - - comments_class = Comment - - def get_query_set(self): - qs = super(LatestCommentsFeed, self).get_query_set() - qs = qs.filter(is_removed=False) - if settings.COMMENTS_BANNED_USERS_GROUP: + qs = comments.get_model().objects.filter( + site__pk = settings.SITE_ID, + is_public = True, + is_removed = False, + ) + if getattr(settings, 'COMMENTS_BANNED_USERS_GROUP', None): where = ['user_id NOT IN (SELECT user_id FROM auth_users_group WHERE group_id = %s)'] params = [settings.COMMENTS_BANNED_USERS_GROUP] qs = qs.extra(where=where, params=params) - return qs + return qs[:40] + + def item_pubdate(self, item): + return item.submit_date \ No newline at end of file diff --git a/django/contrib/comments/forms.py b/django/contrib/comments/forms.py new file mode 100644 index 0000000000..4e7870501e --- /dev/null +++ b/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() diff --git a/django/contrib/comments/managers.py b/django/contrib/comments/managers.py new file mode 100644 index 0000000000..d664fdaca0 --- /dev/null +++ b/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 diff --git a/django/contrib/comments/models.py b/django/contrib/comments/models.py index fdf34c8997..779d67b691 100644 --- a/django/contrib/comments/models.py +++ b/django/contrib/comments/models.py @@ -1,286 +1,185 @@ 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.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.conf import settings -MIN_PHOTO_DIMENSION = 5 -MAX_PHOTO_DIMENSION = 1000 +COMMENT_MAX_LENGTH = getattr(settings,'COMMENT_MAX_LENGTH',3000) -# Option codes for comment-form hidden fields. -PHOTOS_REQUIRED = 'pr' -PHOTOS_OPTIONAL = 'pa' -RATINGS_REQUIRED = 'rr' -RATINGS_OPTIONAL = 'ra' -IS_PUBLIC = 'ip' +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") -# What users get if they don't have any karma. -DEFAULT_KARMA = 5 -KARMA_NEEDED_BEFORE_DISPLAYED = 3 + # Metadata about the comment + site = models.ForeignKey(Site) + class Meta: + abstract = True -class CommentManager(models.Manager): - def get_security_hash(self, options, photo_options, rating_options, target): + def get_content_object_url(self): """ - 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. + Get a URL suitable for redirecting to the content object. Uses the + ``django.views.defaults.shortcut`` view, which thus must be installed. """ - from django.utils.hashcompat import md5_constructor - return md5_constructor(options + photo_options + rating_options + target + settings.SECRET_KEY).hexdigest() + return urlresolvers.reverse( + "django.views.defaults.shortcut", + args=(self.content_type_id, self.object_pk) + ) - 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 +class Comment(BaseCommentAbstractModel): + """ + A user comment about some object. + """ - 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) + # 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) - 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 + 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.')) -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) + # Manager objects = CommentManager() class Meta: - verbose_name = _('comment') - verbose_name_plural = _('comments') - ordering = ('-submit_date',) + db_table = "django_comments" + ordering = ('submit_date',) + permissions = [("can_moderate", "Can moderate comments")] def __unicode__(self): - return "%s: %s..." % (self.user.username, 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_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_userinfo(self): """ - Returns the object that this comment is a comment on. Returns None if - the object no longer exists. + Get a dictionary that pulls together information about the poster + safely for both authenticated and non-authenticated comments. + + This dict will have ``name``, ``email``, and ``url`` fields. """ - from django.core.exceptions import ObjectDoesNotExist - try: - return self.content_type.get_object_for_this_type(pk=self.object_id) - except ObjectDoesNotExist: - return None + 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 - get_content_object.short_description = _('Content object') + # 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 _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_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_good_karma_total(self): - if not hasattr(self, "_karma_total_good"): - self._fill_karma_cache() - return self._karma_total_good + 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_bad_karma_total(self): - if not hasattr(self, "_karma_total_bad"): - self._fill_karma_cache() - return self._karma_total_bad + 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_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_absolute_url(self, anchor_pattern="#c%(id)s"): + return self.get_content_object_url() + (anchor_pattern % self.__dict__) 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()} + """ + Return this comment as plain text. Useful for emails. + """ + 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: -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) + * 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: - verbose_name = _('free comment') - verbose_name_plural = _('free comments') - ordering = ('-submit_date',) + db_table = 'django_comment_flags' + unique_together = [('user', 'comment', 'flag')] def __unicode__(self): - return "%s: %s..." % (self.person_name, self.comment[:100]) + return "%s flag of comment ID %s by %s" % \ + (self.flag, self.comment_id, self.user.username) - def get_absolute_url(self): - try: - return self.get_content_object().get_absolute_url() + "#c" + str(self.id) - except AttributeError: - return "" - - def get_content_object(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() - - def get_pretty_score(self, score): - """ - 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() - - 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): - """ - 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. - """ - 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() - - class Meta: - verbose_name = _('user flag') - verbose_name_plural = _('user flags') - unique_together = (('user', 'comment'),) - - 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) - - 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 - \ No newline at end of file + def save(self): + if self.flag_date is None: + self.flag_date = datetime.datetime.now() + super(CommentFlag, self).save() diff --git a/django/contrib/comments/signals.py b/django/contrib/comments/signals.py new file mode 100644 index 0000000000..346a2e0449 --- /dev/null +++ b/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() diff --git a/django/contrib/comments/templates/comments/400-debug.html b/django/contrib/comments/templates/comments/400-debug.html new file mode 100644 index 0000000000..adb08a16d0 --- /dev/null +++ b/django/contrib/comments/templates/comments/400-debug.html @@ -0,0 +1,53 @@ + + + + + Comment post not allowed (400) + + + + +
+

Comment post not allowed (400)

+ + + + + +
Why:{{ why }}
+
+
+

+ 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 comment + documentation for more help. +

+
+ +
+

+ You're seeing this error because you have DEBUG = True in + your Django settings file. Change that to False, and Django + will display a standard 400 error page. +

+
+ + diff --git a/django/contrib/comments/templates/comments/approve.html b/django/contrib/comments/templates/comments/approve.html new file mode 100644 index 0000000000..18157b77f9 --- /dev/null +++ b/django/contrib/comments/templates/comments/approve.html @@ -0,0 +1,14 @@ +{% extends "comments/base.html" %} + +{% block title %}Approve a comment{% endblock %} + +{% block content %} +

Really make this comment public?

+
{{ comment|escape|linebreaks }}
+
+ +

+ or cancel +

+
+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/approved.html b/django/contrib/comments/templates/comments/approved.html new file mode 100644 index 0000000000..1ff5c42896 --- /dev/null +++ b/django/contrib/comments/templates/comments/approved.html @@ -0,0 +1,7 @@ +{% extends "comments/base.html" %} + +{% block title %}Thanks for approving.{% endblock %} + +{% block content %} +

Thanks for taking the time to improve the quality of discussion on our site.

+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/base.html b/django/contrib/comments/templates/comments/base.html new file mode 100644 index 0000000000..36fc66f7d1 --- /dev/null +++ b/django/contrib/comments/templates/comments/base.html @@ -0,0 +1,10 @@ + + + + + {% block title %}{% endblock %} + + + {% block content %}{% endblock %} + + diff --git a/django/contrib/comments/templates/comments/delete.html b/django/contrib/comments/templates/comments/delete.html new file mode 100644 index 0000000000..6486951a07 --- /dev/null +++ b/django/contrib/comments/templates/comments/delete.html @@ -0,0 +1,14 @@ +{% extends "comments/base.html" %} + +{% block title %}Remove a comment{% endblock %} + +{% block content %} +

Really remove this comment?

+
{{ comment|escape|linebreaks }}
+
+ +

+ or cancel +

+
+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/deleted.html b/django/contrib/comments/templates/comments/deleted.html new file mode 100644 index 0000000000..9c0fc423d4 --- /dev/null +++ b/django/contrib/comments/templates/comments/deleted.html @@ -0,0 +1,7 @@ +{% extends "comments/base.html" %} + +{% block title %}Thanks for removing.{% endblock %} + +{% block content %} +

Thanks for taking the time to improve the quality of discussion on our site.

+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/flag.html b/django/contrib/comments/templates/comments/flag.html new file mode 100644 index 0000000000..b6090e63c3 --- /dev/null +++ b/django/contrib/comments/templates/comments/flag.html @@ -0,0 +1,14 @@ +{% extends "comments/base.html" %} + +{% block title %}Flag this comment{% endblock %} + +{% block content %} +

Really flag this comment?

+
{{ comment|escape|linebreaks }}
+
+ +

+ or cancel +

+
+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/flagged.html b/django/contrib/comments/templates/comments/flagged.html new file mode 100644 index 0000000000..7d2dc11572 --- /dev/null +++ b/django/contrib/comments/templates/comments/flagged.html @@ -0,0 +1,7 @@ +{% extends "comments/base.html" %} + +{% block title %}Thanks for flagging.{% endblock %} + +{% block content %} +

Thanks for taking the time to improve the quality of discussion on our site.

+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/form.html b/django/contrib/comments/templates/comments/form.html index 11eaa8d00d..5c43c62298 100644 --- a/django/contrib/comments/templates/comments/form.html +++ b/django/contrib/comments/templates/comments/form.html @@ -1,38 +1,19 @@ -{% load i18n %} -{% if display_form %} -
- -{% if user.is_authenticated %} -

{% trans "Username:" %} {{ user.username }} ({% trans "Log out" %})

-{% else %} -


{% trans "Password:" %} ({% trans "Forgotten your password?" %})

-{% endif %} - -{% if ratings_optional or ratings_required %} -

{% trans "Ratings" %} ({% if ratings_required %}{% trans "Required" %}{% else %}{% trans "Optional" %}{% endif %}):

- -{% for value in rating_range %}{% endfor %} -{% for rating in rating_choices %} -{% for value in rating_range %}{% endfor %} -{% endfor %} -
 {{ value }}
{{ rating }}
- -{% endif %} - -{% if photos_optional or photos_required %} -

({% if photos_required %}{% trans "Required" %}{% else %}{% trans "Optional" %}{% endif %}): -

- -{% endif %} - -


-

- -

- - - - -

-
-{% endif %} +{% load comments %} +
+ {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% else %} + + {% endif %} + {% endfor %} +

+ + +

+
\ No newline at end of file diff --git a/django/contrib/comments/templates/comments/freeform.html b/django/contrib/comments/templates/comments/freeform.html deleted file mode 100644 index f0d00b91c7..0000000000 --- a/django/contrib/comments/templates/comments/freeform.html +++ /dev/null @@ -1,13 +0,0 @@ -{% load i18n %} -{% if display_form %} -
-

-


-

- - - - -

-
-{% endif %} diff --git a/django/contrib/comments/templates/comments/moderation_queue.html b/django/contrib/comments/templates/comments/moderation_queue.html new file mode 100644 index 0000000000..b5519dfab1 --- /dev/null +++ b/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 }} + +{% endblock %} + +{% block branding %} +

Comment moderation queue

+{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} +{% if empty %} +

No comments to moderate. +{% else %} +

+
+ + + + + + + + + + + + + + + {% for comment in comments %} + + + + + + + + + + + {% endfor %} + +
ActionNameCommentEmailURLAuthenticated?IP AddressDate posted
+
+ + +
+
+ + +
+
{{ comment.name|escape }}{{ comment.comment|truncatewords:"50"|escape }}{{ comment.email|escape }}{{ comment.url|escape }} + {% if comment.user %}yes{% else %}no{% endif %} + {{ comment.ip_address|escape }}{{ comment.submit_date|date:"F j, P" }}
+
+
+{% endif %} +{% endblock %} diff --git a/django/contrib/comments/templates/comments/posted.html b/django/contrib/comments/templates/comments/posted.html new file mode 100644 index 0000000000..2833c11c48 --- /dev/null +++ b/django/contrib/comments/templates/comments/posted.html @@ -0,0 +1,7 @@ +{% extends "comments/base.html" %} + +{% block title %}Thanks for commenting.{% endblock %} + +{% block content %} +

Thank you for your comment.

+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/preview.html b/django/contrib/comments/templates/comments/preview.html new file mode 100644 index 0000000000..50ed6d6fce --- /dev/null +++ b/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 %} +
+ {% if form.errors %} +

Please correct the error{{ form.errors|pluralize }} below

+ {% else %} +

Preview your comment

+
{{ comment|escape|linebreaks }}
+

+ and or make changes: +

+ {% endif %} + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% else %} + + {% endif %} + {% endfor %} +

+ + +

+
+{% endblock %} \ No newline at end of file diff --git a/django/contrib/comments/templates/comments/reply.html b/django/contrib/comments/templates/comments/reply.html new file mode 100644 index 0000000000..4291a90d45 --- /dev/null +++ b/django/contrib/comments/templates/comments/reply.html @@ -0,0 +1,19 @@ +{% load comments %} +
+ {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% else %} + + {% endif %} + {% endfor %} +

+ + +

+
diff --git a/django/contrib/comments/templates/comments/reply_preview.html b/django/contrib/comments/templates/comments/reply_preview.html new file mode 100644 index 0000000000..b566110bcb --- /dev/null +++ b/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 %} +
+ {% if form.errors %} +

Please correct the error{{ form.errors|pluralize }} below

+ {% else %} +

Preview your comment

+
{{ comment|escape|linebreaks }}
+

+ and or make changes: +

+ {% endif %} + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% else %} + + {% endif %} + {% endfor %} +

+ + +

+
+{% endblock %} diff --git a/django/contrib/comments/templatetags/comments.py b/django/contrib/comments/templatetags/comments.py index 959cec4c7f..78f2819678 100644 --- a/django/contrib/comments/templatetags/comments.py +++ b/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.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.utils.encoding import smart_str -import re +from django.contrib import comments register = template.Library() -COMMENT_FORM = 'comments/form.html' -FREE_COMMENT_FORM = 'comments/freeform.html' +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. + """ -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 + #@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): - 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: - try: - self.obj_id = self.obj_id_lookup_var.resolve(context) - 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 - 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) - 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 - - 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 + qs = self.get_query_set(context) + context[self.as_varname] = self.get_context_value_from_queryset(context, qs) 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 {} + 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: + obj = self.object_expr.resolve(context) + except template.VariableDoesNotExist: + return None, None + return ContentType.objects.get_for_model(obj), obj.pk + else: + 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: + return None 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 + context[self.as_varname] = self.get_form(context) return '' -class DoCommentForm: +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): + 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. + +#@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:: - {% 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:: - {% 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:: - {% 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:: - {% 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:: - {% get_comment_list for [pkg].[py_module_name] [context_var_containing_obj_id] as [varname] (reversed) %} - - Example usage:: - - {% get_comment_list for lcom.eventtimes event.id as comment_list %} - - Note: ``[context_var_containing_obj_id]`` can also be a hard-coded integer, like this:: - - {% get_comment_list for lcom.eventtimes 23 as comment_list %} - - To get a list of comments in reverse order -- that is, most recent first -- - pass ``reversed`` as the last param:: - - {% get_comment_list for lcom.eventtimes event.id as comment_list reversed %} + {% get_comment_form for [object] as [varname] %} + {% get_comment_form for [app].[model] [object_id] as [varname] %} """ - def __init__(self, free): - self.free = free + return CommentFormNode.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 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) +#@register.tag +def render_comment_form(parser, token): + """ + Render the comment form (as returned by ``{% render_comment_form %}``) through + the ``comments/form.html`` template. -# 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)) + Syntax:: + + {% render_comment_form for [object] %} + {% render_comment_form for [app].[model] [object_id] %} + """ + return RenderCommentFormNode.handle_token(parser, token) + +#@register.simple_tag +def comment_form_target(): + """ + Get the target URL for the comment form. + + Example:: + +
+ """ + return comments.get_form_target() + +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) diff --git a/django/contrib/comments/tests.py b/django/contrib/comments/tests.py deleted file mode 100644 index a8275debf6..0000000000 --- a/django/contrib/comments/tests.py +++ /dev/null @@ -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 - ->>> print c -commenttestuser: â... -""" - diff --git a/django/contrib/comments/urls.py b/django/contrib/comments/urls.py new file mode 100644 index 0000000000..ed4f89d376 --- /dev/null +++ b/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'), +) + diff --git a/django/contrib/comments/urls/comments.py b/django/contrib/comments/urls/comments.py deleted file mode 100644 index bbb4c435b6..0000000000 --- a/django/contrib/comments/urls/comments.py +++ /dev/null @@ -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\d+)/(?Pup|down)/$', 'karma.vote'), - (r'^flag/(?P\d+)/$', 'userflags.flag'), - (r'^flag/(?P\d+)/done/$', 'userflags.flag_done'), - (r'^delete/(?P\d+)/$', 'userflags.delete'), - (r'^delete/(?P\d+)/done/$', 'userflags.delete_done'), -) diff --git a/django/contrib/comments/views/comments.py b/django/contrib/comments/views/comments.py index ba59cbafc9..f8544937d9 100644 --- a/django/contrib/comments/views/comments.py +++ b/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.db import models from django.shortcuts import render_to_response 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 +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 -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. - -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), + def __init__(self, why): + super(CommentPostBadRequest, self).__init__() + if settings.DEBUG: + self.content = render_to_string("comments/400-debug.html", {"why": why}) + +def post_comment(request, next=None): + """ + Post a comment. + + HTTP POST is required. If ``POST['submit'] == "preview"`` or if there are + errors a preview template, ``comments/preview.html``, will be rendered. + """ + + # 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: + 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: + 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", ] - 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]), + return render_to_response( + template_list, { + "comment" : form.data.get("comment", ""), + "form" : form, + }, + RequestContext(request, {}) ) - def hasNoProfanities(self, field_data, all_data): - if settings.COMMENTS_ALLOW_PROFANITIES: - return - return validators.hasNoProfanities(field_data, all_data) + # 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 - 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) + # Signal that the comment is about to be saved + responses = signals.comment_will_be_posted.send(comment) - 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 + for (receiver, response) in responses: + if response == False: + return CommentPostBadRequest( + "comment_will_be_posted receiver %r killed the comment" % receiver.__name__) -def post_comment(request, extra_context=None, context_processors=None): - """ - Post a comment + # Save the comment and signal that it was saved + comment.save() + signals.comment_was_posted.send(comment) - Redirects to the `comments.comments.comment_was_posted` view upon success. + return next_redirect(data, next, comment_done, c=comment._get_pk_val()) - 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 - """ - 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' - try: - obj = ContentType.objects.get(pk=content_type_id).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(',') # 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'") +comment_done = confirmation_view( + template = "comments/posted.html", + doc = """Display a "comment was posted" success page.""" +) -def post_free_comment(request, extra_context=None, context_processors=None): - """ - Post a free comment (not requiring a log in) - - Redirects to `comments.comments.comment_was_posted` view on success. - - 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'") - -def comment_was_posted(request, extra_context=None, context_processors=None): - """ - Display "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)) diff --git a/django/contrib/comments/views/karma.py b/django/contrib/comments/views/karma.py deleted file mode 100644 index 7c0e284ae9..0000000000 --- a/django/contrib/comments/views/karma.py +++ /dev/null @@ -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)) diff --git a/django/contrib/comments/views/moderation.py b/django/contrib/comments/views/moderation.py new file mode 100644 index 0000000000..0600b5fe08 --- /dev/null +++ b/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.' +) diff --git a/django/contrib/comments/views/userflags.py b/django/contrib/comments/views/userflags.py deleted file mode 100644 index 91518dc5dd..0000000000 --- a/django/contrib/comments/views/userflags.py +++ /dev/null @@ -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)) diff --git a/django/contrib/comments/views/utils.py b/django/contrib/comments/views/utils.py new file mode 100644 index 0000000000..0787e9032e --- /dev/null +++ b/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 diff --git a/docs/_static/djangodocs.css b/docs/_static/djangodocs.css index 940bab8388..a92aff0cd8 100644 --- a/docs/_static/djangodocs.css +++ b/docs/_static/djangodocs.css @@ -62,7 +62,7 @@ ins { font-weight: bold; text-decoration: none; } /*** lists ***/ ul { 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; } ol li { margin-bottom: .4em; } ul ul { padding-left:1.2em; } diff --git a/docs/index.txt b/docs/index.txt index 7b339001e5..ae1dedf8aa 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -72,10 +72,16 @@ Using Django 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 =============================== @@ -95,11 +101,16 @@ Add-on ("contrib") applications 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 ========================= @@ -120,11 +131,14 @@ Solving specific problems And more: --------- -:ref:`Authenticating in Apache ` ... -: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 ` + * :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 ========= @@ -143,9 +157,13 @@ Reference 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 ================ diff --git a/docs/ref/contrib/comments/index.txt b/docs/ref/contrib/comments/index.txt new file mode 100644 index 0000000000..97e69d11cb --- /dev/null +++ b/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 ` + 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 ` 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 %} + +

This event has {{ comment_count }} comments.

+ + +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 ` that +you can use in the template:: + + {% get_comment_form for [object] %} + +A complete form might look like:: + + {% get_comment_form for event %} + + {{ 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:: + +
+ +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 + diff --git a/docs/ref/contrib/comments/settings.txt b/docs/ref/contrib/comments/settings.txt new file mode 100644 index 0000000000..4bd60f7bcb --- /dev/null +++ b/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. diff --git a/docs/ref/contrib/comments/upgrade.txt b/docs/ref/contrib/comments/upgrade.txt new file mode 100644 index 0000000000..0f0b67f219 --- /dev/null +++ b/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 ` and + :ref:`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; diff --git a/docs/ref/contrib/index.txt b/docs/ref/contrib/index.txt index e6c693d905..2f3075ba04 100644 --- a/docs/ref/contrib/index.txt +++ b/docs/ref/contrib/index.txt @@ -26,6 +26,7 @@ those packages have. admin auth + comments/index contenttypes csrf databrowse @@ -58,7 +59,9 @@ See :ref:`topics-auth`. 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 ============ diff --git a/docs/topics/templates.txt b/docs/topics/templates.txt index 09ef0d711a..68ecdffaa4 100644 --- a/docs/topics/templates.txt +++ b/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, variable and object available to you in a given template. +.. _loading-custom-template-libraries: + Custom tag and filter libraries =============================== diff --git a/django/contrib/comments/urls/__init__.py b/tests/regressiontests/comment_tests/__init__.py similarity index 100% rename from django/contrib/comments/urls/__init__.py rename to tests/regressiontests/comment_tests/__init__.py diff --git a/tests/regressiontests/comment_tests/fixtures/comment_tests.json b/tests/regressiontests/comment_tests/fixtures/comment_tests.json new file mode 100644 index 0000000000..b10326930e --- /dev/null +++ b/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" + } + } +] diff --git a/tests/regressiontests/comment_tests/models.py b/tests/regressiontests/comment_tests/models.py new file mode 100644 index 0000000000..28022e2848 --- /dev/null +++ b/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 + diff --git a/tests/regressiontests/comment_tests/tests/__init__.py b/tests/regressiontests/comment_tests/tests/__init__.py new file mode 100644 index 0000000000..ac460d4d0b --- /dev/null +++ b/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 * diff --git a/tests/regressiontests/comment_tests/tests/app_api_tests.py b/tests/regressiontests/comment_tests/tests/app_api_tests.py new file mode 100644 index 0000000000..d4a4488ab5 --- /dev/null +++ b/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/") + diff --git a/tests/regressiontests/comment_tests/tests/comment_form_tests.py b/tests/regressiontests/comment_tests/tests/comment_form_tests.py new file mode 100644 index 0000000000..142931bfd6 --- /dev/null +++ b/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 diff --git a/tests/regressiontests/comment_tests/tests/comment_view_tests.py b/tests/regressiontests/comment_tests/tests/comment_view_tests.py new file mode 100644 index 0000000000..87f5fd372b --- /dev/null +++ b/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)) + diff --git a/tests/regressiontests/comment_tests/tests/model_tests.py b/tests/regressiontests/comment_tests/tests/model_tests.py new file mode 100644 index 0000000000..17797bb7a6 --- /dev/null +++ b/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]) diff --git a/tests/regressiontests/comment_tests/tests/moderation_view_tests.py b/tests/regressiontests/comment_tests/tests/moderation_view_tests.py new file mode 100644 index 0000000000..e5ebbb935f --- /dev/null +++ b/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]) diff --git a/tests/regressiontests/comment_tests/tests/templatetag_tests.py b/tests/regressiontests/comment_tests/tests/templatetag_tests.py new file mode 100644 index 0000000000..a1187ca732 --- /dev/null +++ b/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("")) + + 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 %}")