From 8dac105b96c60b0d8dd9b411bf420e4deb301678 Mon Sep 17 00:00:00 2001 From: Fred Wenzel Date: Thu, 17 Feb 2011 14:29:21 -0800 Subject: [PATCH] Remove tweets button for Army of Awesome. Bug 624464. --- apps/customercare/admin.py | 2 +- apps/customercare/models.py | 1 + .../templates/customercare/landing.html | 1 + .../templates/customercare/tweets.html | 6 ++ apps/customercare/tests/test_views.py | 55 +++++++++++++++++++ apps/customercare/urls.py | 7 ++- apps/customercare/views.py | 44 ++++++++++++++- media/css/customercare.css | 34 ++++++++++-- media/js/customercare.js | 34 +++++++++++- migrations/85-customercare-hidden.sql | 3 + settings.py | 1 + 11 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 migrations/85-customercare-hidden.sql diff --git a/apps/customercare/admin.py b/apps/customercare/admin.py index ba7c5a318..289587522 100644 --- a/apps/customercare/admin.py +++ b/apps/customercare/admin.py @@ -6,7 +6,7 @@ from .models import Tweet, CannedCategory, CannedResponse, CategoryMembership class TweetAdmin(admin.ModelAdmin): date_hierarchy = 'created' list_display = ('tweet_id', '__unicode__', 'created', 'locale') - list_filter = ('locale',) + list_filter = ('locale', 'hidden') search_fields = ('raw_json',) admin.site.register(Tweet, TweetAdmin) diff --git a/apps/customercare/models.py b/apps/customercare/models.py index 3c740db03..2c813ed6d 100644 --- a/apps/customercare/models.py +++ b/apps/customercare/models.py @@ -15,6 +15,7 @@ class Tweet(ModelBase): created = models.DateTimeField(default=datetime.now, db_index=True) reply_to = models.BigIntegerField(blank=True, null=True, default=None, db_index=True) + hidden = models.BooleanField(default=False, db_index=True) class Meta: get_latest_by = 'created' diff --git a/apps/customercare/templates/customercare/landing.html b/apps/customercare/templates/customercare/landing.html index 40b7f4522..d4c12b63b 100644 --- a/apps/customercare/templates/customercare/landing.html +++ b/apps/customercare/templates/customercare/landing.html @@ -45,6 +45,7 @@
+ {{ csrf() }}{# CSRF token for AJAX actions. #} {% if not tweets %}
{% trans language=settings.LOCALES[request.locale].native %} diff --git a/apps/customercare/templates/customercare/tweets.html b/apps/customercare/templates/customercare/tweets.html index 3e6d5eec6..fadf78d63 100644 --- a/apps/customercare/templates/customercare/tweets.html +++ b/apps/customercare/templates/customercare/tweets.html @@ -21,6 +21,12 @@ {% elif settings.CC_SHOW_REPLIES %} {{ _('Reply now') }} {% endif %} + + {% if settings.CC_ALLOW_REMOVE and not (tweet.reply_to or tweet.replies) %} + {{ + _('Remove') }} + {% endif %} +

{{ tweet.text|safe }}

diff --git a/apps/customercare/tests/test_views.py b/apps/customercare/tests/test_views.py index 6fb7b6cc1..dc22c0032 100644 --- a/apps/customercare/tests/test_views.py +++ b/apps/customercare/tests/test_views.py @@ -1,7 +1,12 @@ +from django.conf import settings + +from mock import patch_object from nose.tools import eq_ +from customercare.models import Tweet from customercare.views import _get_tweets from sumo.tests import TestCase +from sumo.urlresolvers import reverse class TweetListTestCase(TestCase): @@ -28,3 +33,53 @@ class TweetListTestCase(TestCase): # max_id. for tweet in tweets_2: assert tweet['id'] < max_id + + def test_hide_tweets(self): + """Try hiding tweets.""" + hide_tweet = lambda id: self.client.post( + reverse('customercare.hide_tweet', locale='en-US'), + {'id': id}) + + tw = Tweet.objects.no_cache().filter(reply_to=None, hidden=False)[0] + r = hide_tweet(tw.tweet_id) + eq_(r.status_code, 200) + + # Re-fetch from database. Should be hidden. + tw = Tweet.objects.no_cache().get(tweet_id=tw.tweet_id) + eq_(tw.hidden, True) + + # Hiding it again should work. + r = hide_tweet(tw.tweet_id) + eq_(r.status_code, 200) + + def test_hide_tweets_with_replies(self): + """Hiding tweets with replies is not allowed.""" + tw = Tweet.objects.filter(reply_to=None)[0] + tw.reply_to = 123 + tw.save() + + r = self.client.post( + reverse('customercare.hide_tweet', locale='en-US'), + {'id': tw.tweet_id}) + eq_(r.status_code, 400) + + def test_hide_tweets_invalid_id(self): + """Invalid tweet IDs shouldn't break anything.""" + hide_tweet = lambda id: self.client.post( + reverse('customercare.hide_tweet', locale='en-US'), + {'id': id}) + + r = hide_tweet(123) + eq_(r.status_code, 404) + + r = hide_tweet('cheesecake') + eq_(r.status_code, 400) + + @patch_object(settings._wrapped, 'CC_ALLOW_REMOVE', False) + def test_hide_tweets_disabled(self): + """Do not allow hiding tweets if feature is disabled.""" + tw = Tweet.objects.filter(reply_to=None)[0] + r = self.client.post( + reverse('customercare.hide_tweet', locale='en-US'), + {'id': tw.tweet_id}) + eq_(r.status_code, 418) # Don't tell a teapot to brew coffee. diff --git a/apps/customercare/urls.py b/apps/customercare/urls.py index 9fffacf05..a9bdf78a9 100644 --- a/apps/customercare/urls.py +++ b/apps/customercare/urls.py @@ -1,7 +1,8 @@ from django.conf.urls.defaults import patterns, url urlpatterns = patterns('customercare.views', - url(r'/more_tweets', 'more_tweets', name="customercare.more_tweets"), - url(r'/twitter_post', 'twitter_post', name="customercare.twitter_post"), - url(r'', 'landing', name='customercare.landing'), + url(r'^/more_tweets$', 'more_tweets', name="customercare.more_tweets"), + url(r'^/twitter_post$', 'twitter_post', name="customercare.twitter_post"), + url(r'^/hide_tweet$', 'hide_tweet', name="customercare.hide_tweet"), + url(r'^$', 'landing', name='customercare.landing'), ) diff --git a/apps/customercare/views.py b/apps/customercare/views.py index 95e2702d1..1d9e59c38 100644 --- a/apps/customercare/views.py +++ b/apps/customercare/views.py @@ -6,7 +6,9 @@ import logging from django.conf import settings from django.core.cache import cache -from django.http import HttpResponseBadRequest +from django.http import (HttpResponse, HttpResponseBadRequest, + HttpResponseNotFound, HttpResponseServerError) +from django.shortcuts import get_object_or_404 from django.views.decorators.http import require_POST, require_GET from babel.numbers import format_number @@ -15,7 +17,7 @@ import jingo from tower import ugettext as _ import tweepy -from .models import CannedCategory, Tweet +from customercare.models import CannedCategory, Tweet import twitter @@ -58,7 +60,7 @@ def _get_tweets(locale=settings.LANGUAGE_CODE, max_id will only return tweets with the status ids less than the given id. """ locale = settings.LOCALES[locale].iso639_1 - q = Tweet.objects.filter(locale=locale, reply_to=reply_to) + q = Tweet.objects.filter(locale=locale, reply_to=reply_to, hidden=False) if max_id: q = q.filter(tweet_id__lt=max_id) if limit: @@ -188,3 +190,39 @@ def twitter_post(request): # We could optimize by not encoding and then decoding JSON. return jingo.render(request, 'customercare/tweets.html', {'tweets': [_tweet_for_template(tweet)]}) + + +@require_POST +def hide_tweet(request): + """ + Hide the tweet with a given ID. Only hides tweets that are not replies + and do not have replies. + + Returns proper HTTP status codes. + """ + # If feature disabled, bail. + if not settings.CC_ALLOW_REMOVE: + return HttpResponse(status=418) # I'm a teapot. + + try: + id = int(request.POST.get('id')) + except (ValueError, TypeError): + return HttpResponseBadRequest(_('Invalid ID.')) + + try: + tweet = Tweet.objects.get(tweet_id=id) + except Tweet.DoesNotExist: + return HttpResponseNotFound(_('Invalid ID.')) + + if tweet.reply_to or Tweet.objects.filter(reply_to=id).count(): + return HttpResponseBadRequest(_('Tweets that are replies or have ' + 'replies must not be hidden.')) + + try: + tweet.hidden = True + tweet.save(force_update=True) + except Exception, e: + return HttpResponseServerError( + _('An error occured: {message}').format(message=e)) + + return HttpResponse('ok') diff --git a/media/css/customercare.css b/media/css/customercare.css index 1f3ed4e5e..f35bae0b9 100644 --- a/media/css/customercare.css +++ b/media/css/customercare.css @@ -155,6 +155,10 @@ div.warning-box { border-top: 1px solid #fef1ad; } +#tweets li { + position: relative; +} + #tweets li img { border: solid 1px #cacccb; float: left; @@ -193,7 +197,23 @@ div.warning-box { width: 550px; } -#tweets li .reply_count { +#tweets .remove_tweet { + display: none; +} +html.js #tweets li:hover .remove_tweet, +#tweets li .remove_tweet.clicked { + display: inline; + position: absolute; + right: 10px; + bottom: 10px; +} +#tweets li .remove_tweet.clicked { + padding-left: 20px; + background: url('../img/customercare/spinner.gif') left top no-repeat; +} + +#tweets li .reply_count, +#tweets li .remove_tweet { clear: right; float: right; font-family: Verdana, sans-serif; @@ -206,14 +226,20 @@ div.warning-box { color: #0ba643; } #tweets li a.reply_count:before { - content: '\25B6\A0'; + content: '\25B6\A0'; /* rightarrow, space */ } #tweets li a.reply_count.opened:before { - content: '\25BC\A0'; + content: '\25BC\A0'; /* downarrow, space */ } -#tweets li a.reply_count:hover { +#tweets li .reply_count:hover, +#tweets li .remove_tweet:hover { text-decoration: underline; } +#tweets li .remove_tweet:before { + color: #e45d49; + content: '\2716\A0'; /* big X, space */ + font-size: 110%; +} .tweets-buttons { float: right; diff --git a/media/js/customercare.js b/media/js/customercare.js index d1e0fd4bf..980b68af6 100644 --- a/media/js/customercare.js +++ b/media/js/customercare.js @@ -1,6 +1,7 @@ (function($){ // Tweet IDs are too high. Using .data('tweet-id') returns incorrect - // results. See jQuery bug 7579 - http://bugs.jquery.com/ticket/7579 + // results. Use .attr('data-tweet-id') instead. + // See jQuery bug 7579 - http://bugs.jquery.com/ticket/7579 function Memory(name) { this._id = null; @@ -373,6 +374,37 @@ e.preventDefault(); }); + /* Remove tweet functionality */ + $('#tweets a.remove_tweet').live('click', function(e) { + if ($(this).hasClass('clicked')) return false; + $(this).addClass('clicked'); + + var tweet = $(this).closest('li'), + tweet_id = tweet.attr('data-tweet-id'); + $.ajax({ + url: $(this).attr('href'), + type: 'POST', + data: { + csrfmiddlewaretoken: $('#tweets-wrap input[name=csrfmiddlewaretoken]').val(), + id: tweet_id + }, + dataType: 'text', + success: function() { + $(this).removeClass('clicked'); + tweet.slideUp('fast', function() { + $(this).remove(); + }); + }, + error: function(err) { + $(this).removeClass('clicked'); + alert('Error removing tweet: ' + err.responseText); + } + }); + + $(this).blur(); + e.preventDefault(); + }); + /* Search box */ $('#side-search input[name="q"]').autoPlaceholderText(); diff --git a/migrations/85-customercare-hidden.sql b/migrations/85-customercare-hidden.sql new file mode 100644 index 000000000..175dee143 --- /dev/null +++ b/migrations/85-customercare-hidden.sql @@ -0,0 +1,3 @@ +-- Allow tweets to be hidden on Army of Awesome page. +ALTER TABLE `customercare_tweet` ADD `hidden` TINYINT(1) NOT NULL DEFAULT '0'; +ALTER TABLE `customercare_tweet` ADD INDEX ( `hidden` ) ; diff --git a/settings.py b/settings.py index 753ffc8d9..d6ce4c9c7 100644 --- a/settings.py +++ b/settings.py @@ -568,6 +568,7 @@ VIDEO_MAX_FILESIZE = 16777216 # 16 megabytes, in bytes CC_MAX_TWEETS = 500 # Max. no. of tweets in DB CC_TWEETS_PERPAGE = 100 # How many tweets to collect in one go. Max: 100. CC_SHOW_REPLIES = True # Show replies to tweets? +CC_ALLOW_REMOVE = True # Allow users to hide tweets? CC_TWEET_ACTIVITY_URL = 'https://metrics.mozilla.com/stats/twitter/armyOfAwesomeKillRate.json' # Tweet activity stats CC_TOP_CONTRIB_URL = 'https://metrics.mozilla.com/stats/twitter/armyOfAwesomeTopSoldiers.json' # Top contributor stats