diff options
Diffstat (limited to 'app')
-rw-r--r-- | app/lib/django_comments/__init__.py | 92 | ||||
-rw-r--r-- | app/lib/django_comments/admin.py | 87 | ||||
-rw-r--r-- | app/lib/django_comments/feeds.py | 32 | ||||
-rw-r--r-- | app/lib/django_comments/forms.py | 197 | ||||
-rw-r--r-- | app/lib/django_comments/managers.py | 22 | ||||
-rw-r--r-- | app/lib/django_comments/models.py | 201 | ||||
-rw-r--r-- | app/lib/django_comments/moderation.py | 357 | ||||
-rw-r--r-- | app/lib/django_comments/signals.py | 21 | ||||
-rw-r--r-- | app/lib/django_comments/templatetags/__init__.py | 0 | ||||
-rw-r--r-- | app/lib/django_comments/templatetags/comments.py | 334 | ||||
-rw-r--r-- | app/lib/django_comments/urls.py | 16 | ||||
-rw-r--r-- | app/lib/django_comments/views/__init__.py | 0 | ||||
-rw-r--r-- | app/lib/django_comments/views/comments.py | 136 | ||||
-rw-r--r-- | app/lib/django_comments/views/moderation.py | 165 | ||||
-rw-r--r-- | app/lib/django_comments/views/utils.py | 71 | ||||
-rw-r--r-- | app/links/sync_links.py | 2 |
16 files changed, 1733 insertions, 0 deletions
diff --git a/app/lib/django_comments/__init__.py b/app/lib/django_comments/__init__.py new file mode 100644 index 0000000..a78b21a --- /dev/null +++ b/app/lib/django_comments/__init__.py @@ -0,0 +1,92 @@ +from django.conf import settings +from django.core import urlresolvers +from django.core.exceptions import ImproperlyConfigured +from django.utils.importlib import import_module + +from django_comments.models import Comment +from django_comments.forms import CommentForm + +DEFAULT_COMMENTS_APP = 'django_comments' + +def get_comment_app(): + """ + Get the comment app (i.e. "django_comments") as defined in the settings + """ + # Make sure the app's in INSTALLED_APPS + comments_app = get_comment_app_name() + 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_module(comments_app) + except ImportError as e: + raise ImproperlyConfigured("The COMMENTS_APP setting refers to "\ + "a non-existing package. (%s)" % e) + + return package + +def get_comment_app_name(): + """ + Returns the name of the comment app (either the setting value, if it + exists, or the default). + """ + return getattr(settings, 'COMMENTS_APP', DEFAULT_COMMENTS_APP) + +def get_model(): + """ + Returns the comment model class. + """ + if get_comment_app_name() != DEFAULT_COMMENTS_APP and hasattr(get_comment_app(), "get_model"): + return get_comment_app().get_model() + else: + return Comment + +def get_form(): + """ + Returns the comment ModelForm class. + """ + if get_comment_app_name() != DEFAULT_COMMENTS_APP and hasattr(get_comment_app(), "get_form"): + return get_comment_app().get_form() + else: + return CommentForm + +def get_form_target(): + """ + Returns the target URL for the comment form submission view. + """ + if get_comment_app_name() != DEFAULT_COMMENTS_APP and hasattr(get_comment_app(), "get_form_target"): + return get_comment_app().get_form_target() + else: + return urlresolvers.reverse("django_comments.views.comments.post_comment") + +def get_flag_url(comment): + """ + Get the URL for the "flag this comment" view. + """ + if get_comment_app_name() != DEFAULT_COMMENTS_APP and hasattr(get_comment_app(), "get_flag_url"): + return get_comment_app().get_flag_url(comment) + else: + return urlresolvers.reverse("django_comments.views.moderation.flag", + args=(comment.id,)) + +def get_delete_url(comment): + """ + Get the URL for the "delete this comment" view. + """ + if get_comment_app_name() != DEFAULT_COMMENTS_APP and hasattr(get_comment_app(), "get_delete_url"): + return get_comment_app().get_delete_url(comment) + else: + return urlresolvers.reverse("django_comments.views.moderation.delete", + args=(comment.id,)) + +def get_approve_url(comment): + """ + Get the URL for the "approve this comment from moderation" view. + """ + if get_comment_app_name() != DEFAULT_COMMENTS_APP and hasattr(get_comment_app(), "get_approve_url"): + return get_comment_app().get_approve_url(comment) + else: + return urlresolvers.reverse("django_comments.views.moderation.approve", + args=(comment.id,)) diff --git a/app/lib/django_comments/admin.py b/app/lib/django_comments/admin.py new file mode 100644 index 0000000..8a5c063 --- /dev/null +++ b/app/lib/django_comments/admin.py @@ -0,0 +1,87 @@ +from __future__ import unicode_literals + +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_lazy as _, ungettext + +from django_comments.models import Comment +from django_comments import get_model +from django_comments.views.moderation import perform_flag, perform_approve, perform_delete + + +class UsernameSearch(object): + """The User object may not be auth.User, so we need to provide + a mechanism for issuing the equivalent of a .filter(user__username=...) + search in CommentAdmin. + """ + def __str__(self): + return 'user__%s' % get_user_model().USERNAME_FIELD + + +class CommentsAdmin(admin.ModelAdmin): + fieldsets = ( + (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')} + ), + ) + + list_display = ('name', 'content_type', 'object_pk', 'ip_address', 'submit_date', 'is_public', 'is_removed') + list_filter = ('submit_date', 'site', 'is_public', 'is_removed') + date_hierarchy = 'submit_date' + ordering = ('-submit_date',) + raw_id_fields = ('user',) + search_fields = ('comment', UsernameSearch(), 'user_name', 'user_email', 'user_url', 'ip_address') + actions = ["flag_comments", "approve_comments", "remove_comments"] + + def get_actions(self, request): + actions = super(CommentsAdmin, self).get_actions(request) + # Only superusers should be able to delete the comments from the DB. + if not request.user.is_superuser and 'delete_selected' in actions: + actions.pop('delete_selected') + if not request.user.has_perm('django_comments.can_moderate'): + if 'approve_comments' in actions: + actions.pop('approve_comments') + if 'remove_comments' in actions: + actions.pop('remove_comments') + return actions + + def flag_comments(self, request, queryset): + self._bulk_flag(request, queryset, perform_flag, + lambda n: ungettext('flagged', 'flagged', n)) + flag_comments.short_description = _("Flag selected comments") + + def approve_comments(self, request, queryset): + self._bulk_flag(request, queryset, perform_approve, + lambda n: ungettext('approved', 'approved', n)) + approve_comments.short_description = _("Approve selected comments") + + def remove_comments(self, request, queryset): + self._bulk_flag(request, queryset, perform_delete, + lambda n: ungettext('removed', 'removed', n)) + remove_comments.short_description = _("Remove selected comments") + + def _bulk_flag(self, request, queryset, action, done_message): + """ + Flag, approve, or remove some comments from an admin action. Actually + calls the `action` argument to perform the heavy lifting. + """ + n_comments = 0 + for comment in queryset: + action(request, comment) + n_comments += 1 + + msg = ungettext('1 comment was successfully %(action)s.', + '%(count)s comments were successfully %(action)s.', + n_comments) + self.message_user(request, msg % {'count': n_comments, 'action': done_message(n_comments)}) + +# Only register the default admin if the model is the built-in comment model +# (this won't be true if there's a custom comment app). +if get_model() is Comment: + admin.site.register(Comment, CommentsAdmin) diff --git a/app/lib/django_comments/feeds.py b/app/lib/django_comments/feeds.py new file mode 100644 index 0000000..0b8ce5b --- /dev/null +++ b/app/lib/django_comments/feeds.py @@ -0,0 +1,32 @@ +from django.contrib.syndication.views import Feed +from django.contrib.sites.models import get_current_site +from django.utils.translation import ugettext as _ + +import django_comments + +class LatestCommentFeed(Feed): + """Feed of latest comments on the current site.""" + + def __call__(self, request, *args, **kwargs): + self.site = get_current_site(request) + return super(LatestCommentFeed, self).__call__(request, *args, **kwargs) + + def title(self): + return _("%(site_name)s comments") % dict(site_name=self.site.name) + + def link(self): + return "http://%s/" % (self.site.domain) + + def description(self): + return _("Latest comments on %(site_name)s") % dict(site_name=self.site.name) + + def items(self): + qs = django_comments.get_model().objects.filter( + site__pk = self.site.pk, + is_public = True, + is_removed = False, + ) + return qs.order_by('-submit_date')[:40] + + def item_pubdate(self, item): + return item.submit_date diff --git a/app/lib/django_comments/forms.py b/app/lib/django_comments/forms.py new file mode 100644 index 0000000..6a3082f --- /dev/null +++ b/app/lib/django_comments/forms.py @@ -0,0 +1,197 @@ +import time +from django import forms +from django.forms.util import ErrorDict +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.utils.crypto import salted_hmac, constant_time_compare +from django.utils.encoding import force_text +from django.utils.text import get_text_list +from django.utils import timezone +from django.utils.translation import ungettext, ugettext, ugettext_lazy as _ + +from django_comments.models import Comment + +COMMENT_MAX_LENGTH = getattr(settings,'COMMENT_MAX_LENGTH', 3000) + +class CommentSecurityForm(forms.Form): + """ + Handles the security aspects (anti-spoofing) for comment forms. + """ + 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(CommentSecurityForm, self).__init__(data=data, initial=initial) + + 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_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 not constant_time_compare(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 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 HMAC security hash from the provided info. + """ + info = (content_type, object_pk, timestamp) + key_salt = "django.contrib.forms.CommentSecurityForm" + value = "-".join(info) + return salted_hmac(key_salt, value).hexdigest() + +class CommentDetailsForm(CommentSecurityForm): + """ + Handles the specific details of the comment (name, comment, etc.). + """ + 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) + + 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") + + CommentModel = self.get_comment_model() + new = CommentModel(**self.get_comment_create_data()) + new = self.check_for_duplicate_comment(new) + + return new + + def get_comment_model(self): + """ + Get the comment model to create with this form. Subclasses in custom + comment apps should override this, get_comment_create_data, and perhaps + check_for_duplicate_comment to provide custom comment models. + """ + return Comment + + def get_comment_create_data(self): + """ + Returns the dict of data to be used to create a comment. Subclasses in + custom comment apps that override get_comment_model can override this + method to add extra fields onto a custom comment model. + """ + return dict( + content_type = ContentType.objects.get_for_model(self.target_object), + object_pk = force_text(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 = timezone.now(), + site_id = settings.SITE_ID, + is_public = True, + is_removed = False, + ) + + def check_for_duplicate_comment(self, new): + """ + Check that a submitted comment isn't a duplicate. This might be caused + by someone posting a comment twice. If it is a dup, silently return the *previous* comment. + """ + possible_duplicates = self.get_comment_model()._default_manager.using( + self.target_object._state.db + ).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 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: + bad_words = [w for w in settings.PROFANITIES_LIST if w in comment.lower()] + if bad_words: + raise forms.ValidationError(ungettext( + "Watch your mouth! The word %s is not allowed here.", + "Watch your mouth! The words %s are not allowed here.", + len(bad_words)) % get_text_list( + ['"%s%s%s"' % (i[0], '-'*(len(i)-2), i[-1]) + for i in bad_words], ugettext('and'))) + return comment + +class CommentForm(CommentDetailsForm): + honeypot = forms.CharField(required=False, + label=_('If you enter anything in this field '\ + 'your comment will be treated as spam')) + + 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 diff --git a/app/lib/django_comments/managers.py b/app/lib/django_comments/managers.py new file mode 100644 index 0000000..bc0fc5f --- /dev/null +++ b/app/lib/django_comments/managers.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.utils.encoding import force_text + +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=force_text(model._get_pk_val())) + return qs diff --git a/app/lib/django_comments/models.py b/app/lib/django_comments/models.py new file mode 100644 index 0000000..08bad3c --- /dev/null +++ b/app/lib/django_comments/models.py @@ -0,0 +1,201 @@ +from django.conf import settings +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.core import urlresolvers +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible + +from django_comments.managers import CommentManager + +COMMENT_MAX_LENGTH = getattr(settings, 'COMMENT_MAX_LENGTH', 3000) + + +class BaseCommentAbstractModel(models.Model): + """ + An abstract base class that any custom comment models probably should + subclass. + """ + + # Content-object field + content_type = models.ForeignKey(ContentType, + verbose_name=_('content type'), + related_name="content_type_set_for_%(class)s") + object_pk = models.TextField(_('object ID')) + content_object = generic.GenericForeignKey(ct_field="content_type", fk_field="object_pk") + + # Metadata about the comment + site = models.ForeignKey(Site) + + class Meta: + abstract = True + + def get_content_object_url(self): + """ + Get a URL suitable for redirecting to the content object. + """ + return urlresolvers.reverse( + "comments-url-redirect", + args=(self.content_type_id, self.object_pk) + ) + + +@python_2_unicode_compatible +class Comment(BaseCommentAbstractModel): + """ + A user comment about some object. + """ + + # Who posted this comment? If ``user`` is set then it was an authenticated + # user; otherwise at least user_name should have been set and the comment + # was posted by a non-authenticated user. + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('user'), + blank=True, null=True, related_name="%(class)s_comments") + user_name = models.CharField(_("user's name"), max_length=50, blank=True) + user_email = models.EmailField(_("user's email address"), blank=True) + user_url = models.URLField(_("user's URL"), blank=True) + + comment = models.TextField(_('comment'), max_length=COMMENT_MAX_LENGTH) + + # Metadata about the comment + submit_date = models.DateTimeField(_('date/time submitted'), default=None) + ip_address = models.GenericIPAddressField(_('IP address'), unpack_ipv4=True, blank=True, null=True) + is_public = models.BooleanField(_('is public'), default=True, + help_text=_('Uncheck this box to make the comment effectively ' \ + 'disappear from the site.')) + is_removed = models.BooleanField(_('is removed'), default=False, + help_text=_('Check this box if the comment is inappropriate. ' \ + 'A "This comment has been removed" message will ' \ + 'be displayed instead.')) + + # Manager + objects = CommentManager() + + class Meta: + db_table = "django_comments" + ordering = ('submit_date',) + permissions = [("can_moderate", "Can moderate comments")] + verbose_name = _('comment') + verbose_name_plural = _('comments') + + def __str__(self): + return "%s: %s..." % (self.name, self.comment[:50]) + + def save(self, *args, **kwargs): + if self.submit_date is None: + self.submit_date = timezone.now() + super(Comment, self).save(*args, **kwargs) + + def _get_userinfo(self): + """ + 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. + """ + if not hasattr(self, "_userinfo"): + userinfo = { + "name": self.user_name, + "email": self.user_email, + "url": self.user_url + } + if self.user_id: + u = self.user + if u.email: + userinfo["email"] = u.email + + # If the user has a full name, use that for the user name. + # However, a given user_name overrides the raw user.username, + # so only use that if this comment has no associated name. + if u.get_full_name(): + userinfo["name"] = self.user.get_full_name() + elif not self.user_name: + userinfo["name"] = u.get_username() + self._userinfo = userinfo + return self._userinfo + userinfo = property(_get_userinfo, doc=_get_userinfo.__doc__) + + def _get_name(self): + return self.userinfo["name"] + + def _set_name(self, val): + if self.user_id: + raise AttributeError(_("This comment was posted by an authenticated "\ + "user and thus the name is read-only.")) + self.user_name = val + name = property(_get_name, _set_name, doc="The name of the user who posted this comment") + + def _get_email(self): + return self.userinfo["email"] + + def _set_email(self, val): + if self.user_id: + raise AttributeError(_("This comment was posted by an authenticated "\ + "user and thus the email is read-only.")) + self.user_email = val + email = property(_get_email, _set_email, doc="The email of the user who posted this comment") + + def _get_url(self): + return self.userinfo["url"] + + def _set_url(self, val): + self.user_url = val + url = property(_get_url, _set_url, doc="The URL given by the user who posted this comment") + + def get_absolute_url(self, anchor_pattern="#c%(id)s"): + return self.get_content_object_url() + (anchor_pattern % self.__dict__) + + def get_as_text(self): + """ + Return this comment as plain text. Useful for emails. + """ + d = { + 'user': self.user or self.name, + '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 + + +@python_2_unicode_compatible +class CommentFlag(models.Model): + """ + Records a flag on a comment. This is intentionally flexible; right now, a + flag could be: + + * A "removal suggestion" -- where a user suggests a comment for (potential) removal. + + * A "moderator deletion" -- used when a moderator deletes a comment. + + You can (ab)use this model to add other flags, if needed. However, by + design users are only allowed to flag a comment with a given flag once; + if you want rating look elsewhere. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('user'), related_name="comment_flags") + comment = models.ForeignKey(Comment, verbose_name=_('comment'), related_name="flags") + flag = models.CharField(_('flag'), max_length=30, db_index=True) + flag_date = models.DateTimeField(_('date'), default=None) + + # Constants for flag types + SUGGEST_REMOVAL = "removal suggestion" + MODERATOR_DELETION = "moderator deletion" + MODERATOR_APPROVAL = "moderator approval" + + class Meta: + db_table = 'django_comment_flags' + unique_together = [('user', 'comment', 'flag')] + verbose_name = _('comment flag') + verbose_name_plural = _('comment flags') + + def __str__(self): + return "%s flag of comment ID %s by %s" % \ + (self.flag, self.comment_id, self.user.get_username()) + + def save(self, *args, **kwargs): + if self.flag_date is None: + self.flag_date = timezone.now() + super(CommentFlag, self).save(*args, **kwargs) diff --git a/app/lib/django_comments/moderation.py b/app/lib/django_comments/moderation.py new file mode 100644 index 0000000..ebb29da --- /dev/null +++ b/app/lib/django_comments/moderation.py @@ -0,0 +1,357 @@ +""" +A generic comment-moderation system which allows configuration of +moderation options on a per-model basis. + +To use, do two things: + +1. Create or import a subclass of ``CommentModerator`` defining the + options you want. + +2. Import ``moderator`` from this module and register one or more + models, passing the models and the ``CommentModerator`` options + class you want to use. + + +Example +------- + +First, we define a simple model class which might represent entries in +a Weblog:: + + from django.db import models + + class Entry(models.Model): + title = models.CharField(maxlength=250) + body = models.TextField() + pub_date = models.DateField() + enable_comments = models.BooleanField() + +Then we create a ``CommentModerator`` subclass specifying some +moderation options:: + + from django_comments.moderation import CommentModerator, moderator + + class EntryModerator(CommentModerator): + email_notification = True + enable_field = 'enable_comments' + +And finally register it for moderation:: + + moderator.register(Entry, EntryModerator) + +This sample class would apply two moderation steps to each new +comment submitted on an Entry: + +* If the entry's ``enable_comments`` field is set to ``False``, the + comment will be rejected (immediately deleted). + +* If the comment is successfully posted, an email notification of the + comment will be sent to site staff. + +For a full list of built-in moderation options and other +configurability, see the documentation for the ``CommentModerator`` +class. + +""" + +import datetime + +from django.conf import settings +from django.core.mail import send_mail +from django.db.models.base import ModelBase +from django.template import Context, loader +from django.contrib.sites.shortcuts import get_current_site +from django.utils import timezone + +import django_comments +from django_comments import signals + +class AlreadyModerated(Exception): + """ + Raised when a model which is already registered for moderation is + attempting to be registered again. + + """ + pass + +class NotModerated(Exception): + """ + Raised when a model which is not registered for moderation is + attempting to be unregistered. + + """ + pass + +class CommentModerator(object): + """ + Encapsulates comment-moderation options for a given model. + + This class is not designed to be used directly, since it doesn't + enable any of the available moderation options. Instead, subclass + it and override attributes to enable different options:: + + ``auto_close_field`` + If this is set to the name of a ``DateField`` or + ``DateTimeField`` on the model for which comments are + being moderated, new comments for objects of that model + will be disallowed (immediately deleted) when a certain + number of days have passed after the date specified in + that field. Must be used in conjunction with + ``close_after``, which specifies the number of days past + which comments should be disallowed. Default value is + ``None``. + + ``auto_moderate_field`` + Like ``auto_close_field``, but instead of outright + deleting new comments when the requisite number of days + have elapsed, it will simply set the ``is_public`` field + of new comments to ``False`` before saving them. Must be + used in conjunction with ``moderate_after``, which + specifies the number of days past which comments should be + moderated. Default value is ``None``. + + ``close_after`` + If ``auto_close_field`` is used, this must specify the + number of days past the value of the field specified by + ``auto_close_field`` after which new comments for an + object should be disallowed. Default value is ``None``. + + ``email_notification`` + If ``True``, any new comment on an object of this model + which survives moderation will generate an email to site + staff. Default value is ``False``. + + ``enable_field`` + If this is set to the name of a ``BooleanField`` on the + model for which comments are being moderated, new comments + on objects of that model will be disallowed (immediately + deleted) whenever the value of that field is ``False`` on + the object the comment would be attached to. Default value + is ``None``. + + ``moderate_after`` + If ``auto_moderate_field`` is used, this must specify the number + of days past the value of the field specified by + ``auto_moderate_field`` after which new comments for an + object should be marked non-public. Default value is + ``None``. + + Most common moderation needs can be covered by changing these + attributes, but further customization can be obtained by + subclassing and overriding the following methods. Each method will + be called with three arguments: ``comment``, which is the comment + being submitted, ``content_object``, which is the object the + comment will be attached to, and ``request``, which is the + ``HttpRequest`` in which the comment is being submitted:: + + ``allow`` + Should return ``True`` if the comment should be allowed to + post on the content object, and ``False`` otherwise (in + which case the comment will be immediately deleted). + + ``email`` + If email notification of the new comment should be sent to + site staff or moderators, this method is responsible for + sending the email. + + ``moderate`` + Should return ``True`` if the comment should be moderated + (in which case its ``is_public`` field will be set to + ``False`` before saving), and ``False`` otherwise (in + which case the ``is_public`` field will not be changed). + + Subclasses which want to introspect the model for which comments + are being moderated can do so through the attribute ``_model``, + which will be the model class. + + """ + auto_close_field = None + auto_moderate_field = None + close_after = None + email_notification = False + enable_field = None + moderate_after = None + + def __init__(self, model): + self._model = model + + def _get_delta(self, now, then): + """ + Internal helper which will return a ``datetime.timedelta`` + representing the time between ``now`` and ``then``. Assumes + ``now`` is a ``datetime.date`` or ``datetime.datetime`` later + than ``then``. + + If ``now`` and ``then`` are not of the same type due to one of + them being a ``datetime.date`` and the other being a + ``datetime.datetime``, both will be coerced to + ``datetime.date`` before calculating the delta. + + """ + if now.__class__ is not then.__class__: + now = datetime.date(now.year, now.month, now.day) + then = datetime.date(then.year, then.month, then.day) + if now < then: + raise ValueError("Cannot determine moderation rules because date field is set to a value in the future") + return now - then + + def allow(self, comment, content_object, request): + """ + Determine whether a given comment is allowed to be posted on + a given object. + + Return ``True`` if the comment should be allowed, ``False + otherwise. + + """ + if self.enable_field: + if not getattr(content_object, self.enable_field): + return False + if self.auto_close_field and self.close_after is not None: + close_after_date = getattr(content_object, self.auto_close_field) + if close_after_date is not None and self._get_delta(timezone.now(), close_after_date).days >= self.close_after: + return False + return True + + def moderate(self, comment, content_object, request): + """ + Determine whether a given comment on a given object should be + allowed to show up immediately, or should be marked non-public + and await approval. + + Return ``True`` if the comment should be moderated (marked + non-public), ``False`` otherwise. + + """ + if self.auto_moderate_field and self.moderate_after is not None: + moderate_after_date = getattr(content_object, self.auto_moderate_field) + if moderate_after_date is not None and self._get_delta(timezone.now(), moderate_after_date).days >= self.moderate_after: + return True + return False + + def email(self, comment, content_object, request): + """ + Send email notification of a new comment to site staff when email + notifications have been requested. + + """ + if not self.email_notification: + return + recipient_list = [manager_tuple[1] for manager_tuple in settings.MANAGERS] + t = loader.get_template('comments/comment_notification_email.txt') + c = Context({ 'comment': comment, + 'content_object': content_object }) + subject = '[%s] New comment posted on "%s"' % (get_current_site(request).name, + content_object) + message = t.render(c) + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True) + +class Moderator(object): + """ + Handles moderation of a set of models. + + An instance of this class will maintain a list of one or more + models registered for comment moderation, and their associated + moderation classes, and apply moderation to all incoming comments. + + To register a model, obtain an instance of ``Moderator`` (this + module exports one as ``moderator``), and call its ``register`` + method, passing the model class and a moderation class (which + should be a subclass of ``CommentModerator``). Note that both of + these should be the actual classes, not instances of the classes. + + To cease moderation for a model, call the ``unregister`` method, + passing the model class. + + For convenience, both ``register`` and ``unregister`` can also + accept a list of model classes in place of a single model; this + allows easier registration of multiple models with the same + ``CommentModerator`` class. + + The actual moderation is applied in two phases: one prior to + saving a new comment, and the other immediately after saving. The + pre-save moderation may mark a comment as non-public or mark it to + be removed; the post-save moderation may delete a comment which + was disallowed (there is currently no way to prevent the comment + being saved once before removal) and, if the comment is still + around, will send any notification emails the comment generated. + + """ + def __init__(self): + self._registry = {} + self.connect() + + def connect(self): + """ + Hook up the moderation methods to pre- and post-save signals + from the comment models. + + """ + signals.comment_will_be_posted.connect(self.pre_save_moderation, sender=django_comments.get_model()) + signals.comment_was_posted.connect(self.post_save_moderation, sender=django_comments.get_model()) + + def register(self, model_or_iterable, moderation_class): + """ + Register a model or a list of models for comment moderation, + using a particular moderation class. + + Raise ``AlreadyModerated`` if any of the models are already + registered. + + """ + if isinstance(model_or_iterable, ModelBase): + model_or_iterable = [model_or_iterable] + for model in model_or_iterable: + if model in self._registry: + raise AlreadyModerated("The model '%s' is already being moderated" % model._meta.module_name) + self._registry[model] = moderation_class(model) + + def unregister(self, model_or_iterable): + """ + Remove a model or a list of models from the list of models + whose comments will be moderated. + + Raise ``NotModerated`` if any of the models are not currently + registered for moderation. + + """ + if isinstance(model_or_iterable, ModelBase): + model_or_iterable = [model_or_iterable] + for model in model_or_iterable: + if model not in self._registry: + raise NotModerated("The model '%s' is not currently being moderated" % model._meta.module_name) + del self._registry[model] + + def pre_save_moderation(self, sender, comment, request, **kwargs): + """ + Apply any necessary pre-save moderation steps to new + comments. + + """ + model = comment.content_type.model_class() + if model not in self._registry: + return + content_object = comment.content_object + moderation_class = self._registry[model] + + # Comment will be disallowed outright (HTTP 403 response) + if not moderation_class.allow(comment, content_object, request): + return False + + if moderation_class.moderate(comment, content_object, request): + comment.is_public = False + + def post_save_moderation(self, sender, comment, request, **kwargs): + """ + Apply any necessary post-save moderation steps to new + comments. + + """ + model = comment.content_type.model_class() + if model not in self._registry: + return + self._registry[model].email(comment, comment.content_object, request) + +# Import this instance in your own code to use in registering +# your models for moderation. +moderator = Moderator() diff --git a/app/lib/django_comments/signals.py b/app/lib/django_comments/signals.py new file mode 100644 index 0000000..079afaf --- /dev/null +++ b/app/lib/django_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 400 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(providing_args=["comment", "request"]) + +# 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(providing_args=["comment", "request"]) + +# 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(providing_args=["comment", "flag", "created", "request"]) diff --git a/app/lib/django_comments/templatetags/__init__.py b/app/lib/django_comments/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/lib/django_comments/templatetags/__init__.py diff --git a/app/lib/django_comments/templatetags/comments.py b/app/lib/django_comments/templatetags/comments.py new file mode 100644 index 0000000..5ff156b --- /dev/null +++ b/app/lib/django_comments/templatetags/comments.py @@ -0,0 +1,334 @@ +from django import template +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_text + +import django_comments + +register = template.Library() + + +class BaseCommentNode(template.Node): + """ + Base helper class (abstract) for handling the get_comment_* template tags. + Looks a bit strange, but the subclasses below should make this a bit more + obvious. + """ + + @classmethod + def handle_token(cls, parser, token): + """Class method to parse get_comment_list/count/form and return a Node.""" + tokens = token.split_contents() + 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]) + + @staticmethod + def lookup_content_type(token, tagname): + try: + app, model = token.split('.') + return ContentType.objects.get_by_natural_key(app, 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)) + + 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 = django_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): + qs = self.get_query_set(context) + context[self.as_varname] = self.get_context_value_from_queryset(context, qs) + return '' + + def get_query_set(self, context): + ctype, object_pk = self.get_target_ctype_pk(context) + if not object_pk: + return self.comment_model.objects.none() + + qs = self.comment_model.objects.filter( + content_type = ctype, + object_pk = smart_text(object_pk), + site__pk = settings.SITE_ID, + ) + + # The is_public and is_removed fields are implementation details of the + # built-in comment model's spam filtering system, so they might not + # be present on a custom comment model subclass. If they exist, we + # should filter on them. + field_names = [f.name for f in self.comment_model._meta.fields] + if 'is_public' in field_names: + qs = qs.filter(is_public=True) + if getattr(settings, 'COMMENTS_HIDE_REMOVED', True) and 'is_removed' in field_names: + 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): + obj = self.get_object(context) + if obj: + return django_comments.get_form()(obj) + else: + return None + + def get_object(self, context): + if self.object_expr: + try: + return self.object_expr.resolve(context) + except template.VariableDoesNotExist: + return None + else: + object_pk = self.object_pk_expr.resolve(context, + ignore_failures=True) + return self.ctype.get_object_for_this_type(pk=object_pk) + + def render(self, context): + context[self.as_varname] = self.get_form(context) + return '' + +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.split_contents() + 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]) + ) + + 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 '' + +class RenderCommentListNode(CommentListNode): + """Render the comment list directly""" + + @classmethod + def handle_token(cls, parser, token): + """Class method to parse render_comment_list and return a Node.""" + tokens = token.split_contents() + if tokens[1] != 'for': + raise template.TemplateSyntaxError("Second argument in %r tag must be 'for'" % tokens[0]) + + # {% render_comment_list for obj %} + if len(tokens) == 3: + return cls(object_expr=parser.compile_filter(tokens[2])) + + # {% render_comment_list 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]) + ) + + def render(self, context): + ctype, object_pk = self.get_target_ctype_pk(context) + if object_pk: + template_search_list = [ + "comments/%s/%s/list.html" % (ctype.app_label, ctype.model), + "comments/%s/list.html" % ctype.app_label, + "comments/list.html" + ] + qs = self.get_query_set(context) + context.push() + liststr = render_to_string(template_search_list, { + "comment_list" : self.get_context_value_from_queryset(context, qs) + }, context) + context.pop() + return liststr + 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): + """ + 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:: + + {% get_comment_count for [object] as [varname] %} + {% get_comment_count for [app].[model] [object_id] as [varname] %} + + Example usage:: + + {% 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 %} + + """ + return CommentCountNode.handle_token(parser, token) + +@register.tag +def get_comment_list(parser, token): + """ + 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_list for [object] as [varname] %} + {% get_comment_list for [app].[model] [object_id] as [varname] %} + + Example usage:: + + {% get_comment_list for event as comment_list %} + {% for comment in comment_list %} + ... + {% endfor %} + + """ + return CommentListNode.handle_token(parser, token) + +@register.tag +def render_comment_list(parser, token): + """ + Render the comment list (as returned by ``{% get_comment_list %}``) + through the ``comments/list.html`` template + + Syntax:: + + {% render_comment_list for [object] %} + {% render_comment_list for [app].[model] [object_id] %} + + Example usage:: + + {% render_comment_list for event %} + + """ + return RenderCommentListNode.handle_token(parser, token) + +@register.tag +def get_comment_form(parser, token): + """ + Get a (new) form object to post a new comment. + + Syntax:: + + {% get_comment_form for [object] as [varname] %} + {% get_comment_form for [app].[model] [object_id] as [varname] %} + """ + return CommentFormNode.handle_token(parser, token) + +@register.tag +def render_comment_form(parser, token): + """ + Render the comment form (as returned by ``{% render_comment_form %}``) through + the ``comments/form.html`` template. + + 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:: + + <form action="{% comment_form_target %}" method="post"> + """ + return django_comments.get_form_target() + +@register.simple_tag +def get_comment_permalink(comment, anchor_pattern=None): + """ + Get the permalink for a comment, optionally specifying the format of the + named anchor to be appended to the end of the URL. + + Example:: + {% get_comment_permalink comment "#c%(id)s-by-%(user_name)s" %} + """ + + if anchor_pattern: + return comment.get_absolute_url(anchor_pattern) + return comment.get_absolute_url() + diff --git a/app/lib/django_comments/urls.py b/app/lib/django_comments/urls.py new file mode 100644 index 0000000..e02bab0 --- /dev/null +++ b/app/lib/django_comments/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls import patterns, url + +urlpatterns = patterns('django_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'^approve/(\d+)/$', 'moderation.approve', name='comments-approve'), + url(r'^approved/$', 'moderation.approve_done', name='comments-approve-done'), +) + +urlpatterns += patterns('', + url(r'^cr/(\d+)/(.+)/$', 'django.contrib.contenttypes.views.shortcut', name='comments-url-redirect'), +) diff --git a/app/lib/django_comments/views/__init__.py b/app/lib/django_comments/views/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/lib/django_comments/views/__init__.py diff --git a/app/lib/django_comments/views/comments.py b/app/lib/django_comments/views/comments.py new file mode 100644 index 0000000..92084b6 --- /dev/null +++ b/app/lib/django_comments/views/comments.py @@ -0,0 +1,136 @@ +from __future__ import absolute_import + +from django import http +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import models +from django.shortcuts import render_to_response +from django.template import RequestContext +from django.template.loader import render_to_string +from django.utils.html import escape +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.http import require_POST + +import django_comments +from django_comments import signals +from django_comments.views.utils import next_redirect, confirmation_view + +class CommentPostBadRequest(http.HttpResponseBadRequest): + """ + 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, why): + super(CommentPostBadRequest, self).__init__() + if settings.DEBUG: + self.content = render_to_string("comments/400-debug.html", {"why": why}) + + +@require_POST +def post_comment(request, next=None, using=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. + """ + # Fill out some initial data fields from an authenticated user, if present + data = request.POST.copy() + if request.user.is_authenticated(): + if not data.get('name', ''): + data["name"] = request.user.get_full_name() or request.user.get_username() + if not data.get('email', ''): + 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.using(using).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))) + except (ValueError, ValidationError) as e: + return CommentPostBadRequest( + "Attempting go get content-type %r and object PK %r exists raised %s" % \ + (escape(ctype), escape(object_pk), e.__class__.__name__)) + + # Do we want to preview the comment? + preview = "preview" in data + + # Construct the comment form + form = django_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 = [ + # These first two exist for purely historical reasons. + # Django v1.0 and v1.1 allowed the underscore format for + # preview templates, so we have to preserve that format. + "comments/%s_%s_preview.html" % (model._meta.app_label, model._meta.model_name), + "comments/%s_preview.html" % model._meta.app_label, + # Now the usual directory based template hierarchy. + "comments/%s/%s/preview.html" % (model._meta.app_label, model._meta.model_name), + "comments/%s/preview.html" % model._meta.app_label, + "comments/preview.html", + ] + return render_to_response( + template_list, { + "comment": form.data.get("comment", ""), + "form": form, + "next": data.get("next", next), + }, + RequestContext(request, {}) + ) + + # 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 + + # Signal that the comment is about to be saved + responses = signals.comment_will_be_posted.send( + sender=comment.__class__, + comment=comment, + request=request + ) + + for (receiver, response) in responses: + if response == False: + return CommentPostBadRequest( + "comment_will_be_posted receiver %r killed the comment" % receiver.__name__) + + # Save the comment and signal that it was saved + comment.save() + signals.comment_was_posted.send( + sender=comment.__class__, + comment=comment, + request=request + ) + + return next_redirect(request, fallback=next or 'comments-comment-done', + c=comment._get_pk_val()) + +comment_done = confirmation_view( + template="comments/posted.html", + doc="""Display a "comment was posted" success page.""" +) diff --git a/app/lib/django_comments/views/moderation.py b/app/lib/django_comments/views/moderation.py new file mode 100644 index 0000000..b35626a --- /dev/null +++ b/app/lib/django_comments/views/moderation.py @@ -0,0 +1,165 @@ +from __future__ import absolute_import + +from django import template +from django.conf import settings +from django.contrib.auth.decorators import login_required, permission_required +from django.shortcuts import get_object_or_404, render_to_response +from django.views.decorators.csrf import csrf_protect + +import django_comments +from django_comments import signals +from django_comments.views.utils import next_redirect, confirmation_view + +@csrf_protect +@login_required +def flag(request, comment_id, next=None): + """ + Flags a comment. Confirmation on GET, action on POST. + + Templates: :template:`comments/flag.html`, + Context: + comment + the flagged `comments.comment` object + """ + comment = get_object_or_404(django_comments.get_model(), pk=comment_id, site__pk=settings.SITE_ID) + + # Flag on POST + if request.method == 'POST': + perform_flag(request, comment) + return next_redirect(request, fallback=next or 'comments-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) + ) + +@csrf_protect +@permission_required("django_comments.can_moderate") +def delete(request, comment_id, next=None): + """ + Deletes a comment. Confirmation on GET, action on POST. Requires the "can + moderate comments" permission. + + Templates: :template:`comments/delete.html`, + Context: + comment + the flagged `comments.comment` object + """ + comment = get_object_or_404(django_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. + perform_delete(request, comment) + return next_redirect(request, fallback=next or 'comments-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) + ) + +@csrf_protect +@permission_required("django_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: :template:`comments/approve.html`, + Context: + comment + the `comments.comment` object for approval + """ + comment = get_object_or_404(django_comments.get_model(), pk=comment_id, site__pk=settings.SITE_ID) + + # Delete on POST + if request.method == 'POST': + # Flag the comment as approved. + perform_approve(request, comment) + return next_redirect(request, fallback=next or 'comments-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) + ) + +# The following functions actually perform the various flag/aprove/delete +# actions. They've been broken out into separate functions to that they +# may be called from admin actions. + +def perform_flag(request, comment): + """ + Actually perform the flagging of a comment from a request. + """ + flag, created = django_comments.models.CommentFlag.objects.get_or_create( + comment = comment, + user = request.user, + flag = django_comments.models.CommentFlag.SUGGEST_REMOVAL + ) + signals.comment_was_flagged.send( + sender = comment.__class__, + comment = comment, + flag = flag, + created = created, + request = request, + ) + +def perform_delete(request, comment): + flag, created = django_comments.models.CommentFlag.objects.get_or_create( + comment = comment, + user = request.user, + flag = django_comments.models.CommentFlag.MODERATOR_DELETION + ) + comment.is_removed = True + comment.save() + signals.comment_was_flagged.send( + sender = comment.__class__, + comment = comment, + flag = flag, + created = created, + request = request, + ) + + +def perform_approve(request, comment): + flag, created = django_comments.models.CommentFlag.objects.get_or_create( + comment = comment, + user = request.user, + flag = django_comments.models.CommentFlag.MODERATOR_APPROVAL, + ) + + comment.is_removed = False + comment.is_public = True + comment.save() + + signals.comment_was_flagged.send( + sender = comment.__class__, + comment = comment, + flag = flag, + created = created, + request = request, + ) + +# Confirmation views. + +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/app/lib/django_comments/views/utils.py b/app/lib/django_comments/views/utils.py new file mode 100644 index 0000000..314ab8c --- /dev/null +++ b/app/lib/django_comments/views/utils.py @@ -0,0 +1,71 @@ +""" +A few bits of helper functions for comment views. +""" + +import textwrap +try: + from urllib.parse import urlencode +except ImportError: # Python 2 + from urllib import urlencode + +from django.http import HttpResponseRedirect +from django.shortcuts import render_to_response, resolve_url +from django.template import RequestContext +from django.core.exceptions import ObjectDoesNotExist +from django.utils.http import is_safe_url + +import django_comments + +def next_redirect(request, fallback, **get_kwargs): + """ + Handle the "where should I go next?" part of comment views. + + The next value could be a + ``?next=...`` GET arg or the URL of a given view (``fallback``). See + the view modules for examples. + + Returns an ``HttpResponseRedirect``. + """ + next = request.POST.get('next') + if not is_safe_url(url=next, host=request.get_host()): + next = resolve_url(fallback) + + if get_kwargs: + if '#' in next: + tmp = next.rsplit('#', 1) + next = tmp[0] + anchor = '#' + tmp[1] + else: + anchor = '' + + joiner = ('?' in next) and '&' or '?' + next += joiner + urlencode(get_kwargs) + anchor + 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 = django_comments.get_model().objects.get(pk=request.GET['c']) + except (ObjectDoesNotExist, ValueError): + pass + return render_to_response(template, + {'comment': comment}, + context_instance=RequestContext(request) + ) + + confirmed.__doc__ = textwrap.dedent("""\ + %s + + Templates: :template:`%s`` + Context: + comment + The posted comment + """ % (doc, template) + ) + return confirmed diff --git a/app/links/sync_links.py b/app/links/sync_links.py index 7a0cd9c..a9fa1bf 100644 --- a/app/links/sync_links.py +++ b/app/links/sync_links.py @@ -1,6 +1,7 @@ import sys import os from os.path import dirname, abspath +import django PROJECT_ROOT = abspath(dirname(dirname(dirname(__file__)))) + '/' #PROJECT_ROOT = abspath(dirname(dirname(__file__))) sys.path.append(PROJECT_ROOT) @@ -9,5 +10,6 @@ sys.path.append(PROJECT_ROOT + '/app/lib') sys.path.append(PROJECT_ROOT + '/config') sys.path.append('/home/luxagraf/apps/venv/bin/python2.7/') os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings' +django.setup() from links import retriever retriever.sync_pinboard_links() |