From 1ad3f3567fc18a88e9482a76a2f8ebe73149a800 Mon Sep 17 00:00:00 2001 From: Alex Buchanan Date: Fri, 24 Sep 2010 14:33:14 -0700 Subject: [PATCH] Customer Care. Twitter OAuth and template fixes. --- apps/customercare/models.py | 2 +- .../templates/customercare/landing.html | 43 ++++--- .../templates/customercare/reply_modal.html | 64 ++++++----- .../templates/customercare/twitter_modal.html | 6 + apps/customercare/urls.py | 2 +- apps/customercare/views.py | 105 +++-------------- apps/twitter/__init__.py | 89 +++++++++++++++ apps/twitter/middleware.py | 79 +++++++++++++ apps/twitter/models.py | 0 media/css/customercare.css | 15 ++- media/css/sidebar.css | 14 +++ ...olved-back.png => side-getinvolved-bg.png} | Bin media/img/{customercare => }/sumo-logo.png | Bin media/js/customercare.js | 106 ++++++++++-------- settings.py | 4 +- templates/layout/sidebar.html | 8 ++ 16 files changed, 338 insertions(+), 199 deletions(-) create mode 100644 apps/customercare/templates/customercare/twitter_modal.html create mode 100644 apps/twitter/__init__.py create mode 100644 apps/twitter/middleware.py create mode 100644 apps/twitter/models.py rename media/img/{customercare/wantgetinvolved-back.png => side-getinvolved-bg.png} (100%) rename media/img/{customercare => }/sumo-logo.png (100%) diff --git a/apps/customercare/models.py b/apps/customercare/models.py index 52a5d0cdb..7428b01ad 100644 --- a/apps/customercare/models.py +++ b/apps/customercare/models.py @@ -8,7 +8,7 @@ from sumo.models import ModelBase class Tweet(ModelBase): """An entry on twitter.""" - tweet_id = models.BigIntegerField() + tweet_id = models.BigIntegerField(unique=True) raw_json = models.TextField() locale = models.CharField(max_length=20, db_index=True) created = models.DateTimeField(default=datetime.now, db_index=True) diff --git a/apps/customercare/templates/customercare/landing.html b/apps/customercare/templates/customercare/landing.html index 06ce9fbfe..05c47d2e7 100644 --- a/apps/customercare/templates/customercare/landing.html +++ b/apps/customercare/templates/customercare/landing.html @@ -5,46 +5,41 @@ {% block breadcrumbs %}{% endblock %} {% block content_area %} -
-

Join our
Army of Awesome

-

Love Firefox and have a few moments to help? Help other Firefox users on Twitter. Good things will come to those who tweet!

-
+
+

Join our
Army of Awesome

+

Love Firefox and have a few moments to help? Help other Firefox users on Twitter. Good things will come to those who tweet!

+
-
-
    -
  1. Choose a tweet below
  2. - -
  3. Respond to the tweet!
  4. -
-
-
+
+
    +
  1. Choose a tweet below
  2. + +
  3. Respond to the tweet!
  4. +
+

Choose a tweet to help

+ {% if authed %} + Log out of Twitter + {% endif %}

-
- {% include 'customercare/reply_modal.html' %} -
+ {% include 'customercare/reply_modal.html' %} -
-

Sign in with your Twitter account

-

Before you join the Army of Awesome, you need to log in so you can respond to tweets. You will now be redirected to Twitter to log in.

- Sign in - Cancel -
+ {% include 'customercare/twitter_modal.html' %} {% endblock %} diff --git a/apps/customercare/templates/customercare/reply_modal.html b/apps/customercare/templates/customercare/reply_modal.html index 24a1ec0a6..8315cdb3f 100644 --- a/apps/customercare/templates/customercare/reply_modal.html +++ b/apps/customercare/templates/customercare/reply_modal.html @@ -1,47 +1,51 @@ -
-
+
+
+ +
- - - + + + -
- -
-

What is your reply about?

-
- {% for resp in canned_responses %} -

{{ resp.title }}

-
-
    - {% for topic in resp.responses.all() %} -
  • - {{ topic.title }} - {{ topic.response }} #fxhelp -
  • - {% endfor %} -
-
- {% endfor %}
- -
+
+

What is your reply about?

+
+ {% for resp in canned_responses %} +

{{ resp.title }}

+
+
    + {% for topic in resp.responses.all() %} +
  • + {{ topic.title }} + {{ topic.response }} #fxhelp +
  • + {% endfor %} +
+
+ {% endfor %} +
-
-

Get personal

+
+ +
+

Get personal

140
+
- Your message was sent! - -
+ Your message was sent! + + +
+
diff --git a/apps/customercare/templates/customercare/twitter_modal.html b/apps/customercare/templates/customercare/twitter_modal.html new file mode 100644 index 000000000..04cb9fb98 --- /dev/null +++ b/apps/customercare/templates/customercare/twitter_modal.html @@ -0,0 +1,6 @@ +
+

Sign in with your Twitter account

+

Before you join the Army of Awesome, you need to log in so you can respond to tweets. You will now be redirected to Twitter to log in.

+ Cancel + +
diff --git a/apps/customercare/urls.py b/apps/customercare/urls.py index 4acd2e50a..95c98bb85 100644 --- a/apps/customercare/urls.py +++ b/apps/customercare/urls.py @@ -1,6 +1,6 @@ from django.conf.urls.defaults import patterns, url urlpatterns = patterns('customercare.views', - url(r'/twitter_auth', 'twitter_auth', name="customercare.twitter_auth"), + url(r'/twitter_post', 'twitter_post', name="customercare.twitter_post"), url(r'', 'landing', name='customercare.landing'), ) diff --git a/apps/customercare/views.py b/apps/customercare/views.py index 175a79567..f3075c350 100644 --- a/apps/customercare/views.py +++ b/apps/customercare/views.py @@ -2,55 +2,24 @@ from datetime import datetime from email.Utils import parsedate import json import logging -from uuid import uuid4 from django import http -from django.conf import settings -from django.core.cache import cache +from django.views.decorators.csrf import csrf_exempt import jingo -import tweepy from .models import CannedCategory, Tweet +import twitter + +log = logging.getLogger('k') -log = logging.getLogger('custcare') - -token_cache_prefix = 'custcare_token_' -key_prefix = token_cache_prefix + 'key_' -secret_prefix = token_cache_prefix + 'secret_' - -# cookie names are duplicated in js/cusomtercare.js -access_cookie_name = 'custcare_twitter_access_id' -redirect_cookie_name = 'custcare_twitter_redirect_flag' - - -def auth_factory(request): - return tweepy.OAuthHandler(settings.TWITTER_CONSUMER_KEY, - settings.TWITTER_CONSUMER_SECRET, - 'https://{0}/{1}/customercare/'.format( - request.get_host(), request.locale)) - - -def set_access_cookie(resp, id): - resp.set_cookie(redirect_cookie_name, '1', httponly=True) - resp.set_cookie(access_cookie_name, id, secure=True) - - -def set_tokens(id, key, secret): - cache.set(key_prefix + id, key) - cache.set(secret_prefix + id, secret) - - -def get_tokens(id): - key = cache.get(key_prefix + id) - secret = cache.get(secret_prefix + id) - return key, secret - - +@twitter.auth_wanted def landing(request): """Customer Care Landing page.""" + twitter = request.twitter + canned_responses = CannedCategory.objects.all() tweets = [] for tweet in Tweet.objects.filter(locale='en')[:10]: @@ -61,64 +30,26 @@ def landing(request): 'profile_img': data['profile_image_url'], 'user': data['from_user'], 'text': tweet, + 'reply_to': tweet.tweet_id, 'date': date, }) resp = jingo.render(request, 'customercare/landing.html', { 'canned_responses': canned_responses, 'tweets': tweets, - 'now': datetime.utcnow(), + 'authed': twitter.authed, }) - # TODO HTTP redirect flag checking? - if request.COOKIES.get(redirect_cookie_name): - return http.HttpResponseRedirect('https://{0}/{1}'.format( - request.get_host(), request.get_full_path())) - - # if GET[oauth_verifier] exists, we're handling an OAuth login - verifier = request.GET.get('oauth_verifier') - if verifier: - auth = auth_factory(request) - request_key = request.COOKIES.get('request_token_key') - request_secret = request.COOKIES.get('request_token_secret') - if request_key and request_secret: - resp.delete_cookie('request_token_key') - resp.delete_cookie('request_token_secret') - auth.set_request_token(request_key, request_secret) - - try: - auth.get_access_token(verifier) - except tweepy.TweepError: - log.warning('Tweepy Error with verifier token') - pass - else: - access_id = uuid4().hex - set_access_cookie(resp, access_id) - set_tokens(access_id, auth.access_token.key, auth.access_token.secret) - return resp +@csrf_exempt +@twitter.auth_required def twitter_post(request): - # access_id = request.COOKIES.get(access_cookie_name) - # if access_id: - # key, secret = get_tokens(access_id) - # authed = True - # resp.write('key: %s sec: %s' % (key, secret)) - # set_access_cookie(resp, access_id) - pass - - -def twitter_auth(request): - auth = auth_factory(request) - - try: - redirect_url = auth.get_authorization_url() - except tweepy.TweepError: - log.warning('Tweepy error while getting authorization url') - return http.HttpReponseServerError() - - resp = http.HttpResponseRedirect(redirect_url) - resp.set_cookie('request_token_key', auth.request_token.key, max_age=3600, secure=True) - resp.set_cookie('request_token_secret', auth.request_token.secret, max_age=3600, secure=True) - return resp + # FIXME ensure post length is under twitter limit + # do this in JS too + tweet = request.POST.get('tweet') + reply_to = request.POST.get('reply_to') + # TODO remove debug line + request.twitter.api.update_status(tweet, '25684040574') + return http.HttpResponse() diff --git a/apps/twitter/__init__.py b/apps/twitter/__init__.py new file mode 100644 index 000000000..e347d2929 --- /dev/null +++ b/apps/twitter/__init__.py @@ -0,0 +1,89 @@ +import logging +from uuid import uuid4 + +from django import http +from django.core.cache import cache + +import tweepy + + +log = logging.getLogger('k') + +PREFIX = 'custcare_' +ACCESS_NAME = PREFIX + 'access' +REDIRECT_NAME = PREFIX + 'redirect' +REQUEST_KEY_NAME = PREFIX + 'request_key' +REQUEST_SECRET_NAME = PREFIX + 'request_secret' + +MAX_AGE = 3600 + + +def ssl_url(request): + return 'https://{0}{1}'.format(request.get_host(), request.get_full_path()) + +# Twitter sessions are SSL only, so redirect to SSL if needed +def auth_wanted(view_func): + def wrapper(request, *args, **kwargs): + if request.COOKIES.get(REDIRECT_NAME) and not request.is_secure(): + return http.HttpResponseRedirect(ssl_url(request)) + return view_func(request, *args, **kwargs) + return wrapper + +# returns a HttpResponseBadRequest in not authed +def auth_required(view_func): + def wrapper(request, *args, **kwargs): + if not request.twitter.authed: + return http.HttpResponseBadRequest() + return view_func(request, *args, **kwargs) + return wrapper + + +class Session(object): + id = None + key = None + secret = None + + @property + def cachekey_key(self): + return '{0}_key_{1}'.format(ACCESS_NAME, self.id) + + @property + def cachekey_secret(self): + return '{0}_secret_{1}'.format(ACCESS_NAME, self.id) + + @property + def authed(self): + return bool(self.id and self.key and self.secret) + + @classmethod + def factory(cls, key=None, secret=None): + s = cls() + s.id = uuid4().hex + s.key = key + s.secret = secret + return s + + @classmethod + def from_request(cls, request): + s = cls() + s.id = request.COOKIES.get(ACCESS_NAME) + s.key = cache.get(s.cachekey_key) + s.secret = cache.get(s.cachekey_secret) + return s + + def delete(self, response): + response.delete_cookie(REDIRECT_NAME) + response.delete_cookie(ACCESS_NAME) + cache.delete(self.cachekey_key) + cache.delete(self.cachekey_secret) + self.id = None + self.key = None + self.secret = None + + def save(self, response): + cache.set(self.cachekey_key, self.key, MAX_AGE) + cache.set(self.cachekey_secret, self.secret, MAX_AGE) + response.set_cookie(REDIRECT_NAME, '1', max_age=MAX_AGE) + response.set_cookie(ACCESS_NAME, self.id, max_age=MAX_AGE, secure=True) + + diff --git a/apps/twitter/middleware.py b/apps/twitter/middleware.py new file mode 100644 index 000000000..1c86adf4c --- /dev/null +++ b/apps/twitter/middleware.py @@ -0,0 +1,79 @@ +import logging + +from django import http +from django.conf import settings + +from . import * +import tweepy + + +log = logging.getLogger('k') + + +class SessionMiddleware(object): + + def process_request(self, request): + if getattr(request, 'twitter', False): + return + + request.twitter = Session.from_request(request) + + auth = tweepy.OAuthHandler(settings.TWITTER_CONSUMER_KEY, + settings.TWITTER_CONSUMER_SECRET, + ssl_url(request)) + + if request.GET.get('twitter_delete_auth'): + request.twitter = Session.factory() + + elif request.twitter.authed: + auth.set_access_token(request.twitter.key, request.twitter.secret) + request.twitter.api = tweepy.API(auth) + + else: + + verifier = request.GET.get('oauth_verifier') + if verifier: + # We are completing an OAuth login + + request_key = request.COOKIES.get(REQUEST_KEY_NAME) + request_secret = request.COOKIES.get(REQUEST_SECRET_NAME) + + if request_key and request_secret: + auth.set_request_token(request_key, request_secret) + + try: + auth.get_access_token(verifier) + except tweepy.TweepError: + log.warning('Tweepy Error with verifier token') + pass + else: + request.twitter = Session.factory( + auth.access_token.key, auth.access_token.secret) + + elif request.GET.get('twitter_auth_request'): + # We are requesting Twitter auth + + try: + redirect_url = auth.get_authorization_url() + except tweepy.TweepError: + log.warning('Tweepy error while getting authorization url') + else: + response = http.HttpResponseRedirect(redirect_url) + response.set_cookie(REQUEST_KEY_NAME, auth.request_token.key, + max_age=MAX_AGE, secure=True) + response.set_cookie(REQUEST_SECRET_NAME, auth.request_token.secret, + max_age=MAX_AGE, secure=True) + return response + + + def process_response(self, request, response): + if getattr(request, 'twitter', False): + if request.GET.get('twitter_delete_auth'): + request.twitter.delete(response) + + if request.twitter.authed: + response.delete_cookie(REQUEST_KEY_NAME) + response.delete_cookie(REQUEST_SECRET_NAME) + request.twitter.save(response) + + return response diff --git a/apps/twitter/models.py b/apps/twitter/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/media/css/customercare.css b/media/css/customercare.css index 4e4b6f542..ed5df7ca3 100644 --- a/media/css/customercare.css +++ b/media/css/customercare.css @@ -26,6 +26,7 @@ body { } #speach-bubbles { padding: 60px 0 15px 25px; + float: left; } #speach-bubbles li { list-style-type: none; @@ -46,7 +47,8 @@ body { #tweetcontainer { border: 1px solid #f5f5f5; padding: 2px; - clear: both; + float: left; + clear: left; } #tweetcontainer h2 { margin: 0px; @@ -93,24 +95,21 @@ body { width: 48px; height: 48px; } -#tweets li span { - display: block; -} -#tweets li span { +#tweets li .twittername { color: #307fc1; text-decoration: none; font-weight: bold; font-size: 13px; line-height: 1.4em; } -#tweets li span .time { +#tweets li .time { float: right; font-weight: normal; font-style: italic; font-size: 12px; color: #afaba3; } -#tweets li span .text { +#tweets li .text { font-weight: normal; font-size: 14px; color: #69645b; @@ -281,7 +280,7 @@ body { display: inline; } -#reply .submit-message { +#submit-message { display: none; background: url('../img/customercare/reply-check.png') right top no-repeat; font-weight: bold; diff --git a/media/css/sidebar.css b/media/css/sidebar.css index 48987f7b8..073986d69 100644 --- a/media/css/sidebar.css +++ b/media/css/sidebar.css @@ -67,4 +67,18 @@ text-decoration: underline; } +#side-getinvolved { + background: transparent url('../img/side-getinvolved-bg.png') no-repeat top left; + margin-top: 140px; + padding-top: 20px; + color: #999186; +} +#side-getinvolved h3 { + color: #1E4262; + font-size: 150%; +} +#side-getinvolved p { + padding-top: 10px; + font-size: 115%; +} diff --git a/media/img/customercare/wantgetinvolved-back.png b/media/img/side-getinvolved-bg.png similarity index 100% rename from media/img/customercare/wantgetinvolved-back.png rename to media/img/side-getinvolved-bg.png diff --git a/media/img/customercare/sumo-logo.png b/media/img/sumo-logo.png similarity index 100% rename from media/img/customercare/sumo-logo.png rename to media/img/sumo-logo.png diff --git a/media/js/customercare.js b/media/js/customercare.js index 1815e18af..40ab7b38c 100644 --- a/media/js/customercare.js +++ b/media/js/customercare.js @@ -1,56 +1,68 @@ -var has_twitter_access = false; -// cookie names are duplicated in apps/customercare/views.py -if ($.cookie('custcare_twitter_access_id')) - has_twitter_access = true; - - $(document).ready(function() { - $('.reply-message').NobleCount('.character-counter'); + $('.reply-message').NobleCount('.character-counter'); - $('.reply-message').autoPlaceholderText(); + $('.reply-message').autoPlaceholderText(); - $('#accordion').accordion({ - 'icons': false, - 'autoHeight': false, - }); + $('#accordion').accordion({ + 'icons': false, + 'autoHeight': false, + }); + + $('.tweet').click(function() { + var twitter_modal = $('#twitter-modal'); + if (twitter_modal.attr('data-authed') == 'False') { + twitter_modal.dialog({ + 'modal': 'true', + 'position': 'top', + }); + twitter_modal.find('.cancel').click(function(e) { + twitter_modal.dialog('close'); + e.preventDefault(); + return false; + }); + return; + } + + var reply_to = $(this).attr('data-reply_to') + var avatar_href = $(this).find('.avatar').attr('href'); + var avatar_img = $(this).find('.avatar img').attr('src'); + var twittername = $(this).find('.twittername').text(); + var text = $(this).find('.text').text(); + + var modal = $('#reply-modal'); + modal.find('#reply_to').val(reply_to); + modal.find('.avatar').attr('href', avatar_href); + modal.find('.avatar img').attr('src', avatar_img); + modal.find('.twittername').text(twittername); + modal.find('.text').text(text); + modal.dialog({ + 'modal': true, + 'position': 'top', + 'width': 500, + }); + }); + + $('.reply-topic').click(function(e) { + snippet = $(this).next('.snippet').text(); + $('.reply-message').val(snippet); + $('.reply-message').trigger('keydown'); - $('.tweet').click(function() { - if (!has_twitter_access) { - $('#twitter-modal').dialog({ - 'modal': 'true', - 'position': 'top', - }); - $('#twitter-modal .cancel').click(function(e) { - $('#twitter-modal').dialog('close'); e.preventDefault(); return false; - }); - return; - } - - var avatar_href = $(this).find('.avatar').attr('href'); - var avatar_img = $(this).find('.avatar img').attr('src'); - var twittername = $(this).find('.twittername').text(); - var text = $(this).find('.text').text(); - - var modal = $('#reply-modal'); - modal.find('.avatar').attr('href', avatar_href); - modal.find('.avatar img').attr('src', avatar_img); - modal.find('.twittername').text(twittername); - modal.find('.text').text(text); - modal.dialog({ - 'modal': true, - 'position': 'top', - 'width': 500, }); - }); - $('.reply-topic').click(function(e) { - snippet = $(this).next('.snippet').text(); - $('.reply-message').val(snippet); - $('.reply-message').trigger('keydown'); - - e.preventDefault(); - return false; - }); + $('#reply-modal #submit').click(function(e) { + var action = $('#reply-modal form').attr('action'); + var tweet = $('.reply-message').val(); + var reply_to = $('#reply_to').val(); + $.post( + action, + { 'tweet': tweet, 'reply_to': reply_to }, + function() { + $('#submit-message').show(); + } + ); + e.preventDefault(); + return false; + }); }); diff --git a/settings.py b/settings.py index 3ad289be6..02542b95c 100644 --- a/settings.py +++ b/settings.py @@ -142,6 +142,8 @@ MIDDLEWARE_CLASSES = ( # TODO: Replace with Kitsune auth. 'sumo.middleware.TikiCookieMiddleware', + + 'twitter.middleware.SessionMiddleware', ) # Auth @@ -189,6 +191,7 @@ INSTALLED_APPS = ( 'wiki', 'gallery', 'customercare', + 'twitter', ) # Extra apps for testing @@ -330,7 +333,6 @@ MINIFY_BUNDLES = { 'customercare': ( 'js/libs/jqueryui.min.js', 'js/libs/jquery.NobleCount.js', - 'js/libs/jquery.cookie.js', 'js/customercare.js', ), }, diff --git a/templates/layout/sidebar.html b/templates/layout/sidebar.html index 8d20e9991..e3a3a9778 100644 --- a/templates/layout/sidebar.html +++ b/templates/layout/sidebar.html @@ -14,4 +14,12 @@
  • {{ _('Log In') }}
  • {% endif %} + +
    +

    Want to get involved?

    +

    Did you know that most of the content on Firefox Support was written by volunteers?

    +

    Find out how to contribute,
    or log in.

    +

    +
    + {% endblock %}