From 6270daa9c39c5c2a360c63c053a97c0c18d2df2e Mon Sep 17 00:00:00 2001 From: Lyndsy Simon Date: Sat, 16 Mar 2013 11:35:17 -0700 Subject: [PATCH 001/101] Participant pages work for Google users. Tests pass! --- gittip/elsewhere/google.py | 88 ++++++++++++ gittip/models/participant.py | 17 ++- www/%username/index.html | 4 +- www/on/google/%screen_name/index.html | 162 ++++++++++++++++++++++ www/on/google/%screen_name/lock-fail.html | 18 +++ www/on/google/associate | 108 +++++++++++++++ www/on/google/redirect | 39 ++++++ 7 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 gittip/elsewhere/google.py create mode 100644 www/on/google/%screen_name/index.html create mode 100644 www/on/google/%screen_name/lock-fail.html create mode 100644 www/on/google/associate create mode 100644 www/on/google/redirect diff --git a/gittip/elsewhere/google.py b/gittip/elsewhere/google.py new file mode 100644 index 0000000000..cdad48e6ee --- /dev/null +++ b/gittip/elsewhere/google.py @@ -0,0 +1,88 @@ +import datetime +import gittip +import requests +from aspen import json, log, Response +from aspen.utils import to_age, utc, typecheck +from aspen.website import Website +from gittip.elsewhere import AccountElsewhere, ACTIONS, _resolve + + +class GoogleAccount(AccountElsewhere): + platform = u'google' + + def get_url(self): + return "https://plus.google.com/" + self.user_info['screen_name'] + + +def resolve(screen_name): + return _resolve(u'google', u'screen_name', screen_name) + + +def oauth_url(website, action, then=""): + """Return a URL to start oauth dancing with Google. + + """ + typecheck(website, Website, action, unicode, then, unicode) + assert action in ACTIONS + + # Pack action,then into data and base64-encode. Querystring isn't + # available because it's consumed by the initial GitHub request. + + data = u'%s,%s' % (action, then) + data = data.encode('UTF-8').encode('base64').decode('US-ASCII') + + url = u'https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=%s&redirect_uri=%s&state=%s' + url %= (website.google_client_id, website.google_callback, data) + + return url + + +def get_user_info(screen_name): + """Given a unicode, return a dict. + """ + typecheck(screen_name, unicode) + rec = gittip.db.fetchone( "SELECT user_info FROM elsewhere " + "WHERE platform='google' " + "AND user_info->'screen_name' = %s" + , (screen_name,) + ) + if rec is not None: + user_info = rec['user_info'] + else: + url = "https://www.googleapis.com/plus/v1/people/%s?key=AIzaSyDFwxAtyIPi08FgI58rMsL5A9CqvL3kOaY" + user_info = requests.get(url % screen_name) + + + # Keep an eye on our API usage. + # ================================= + + # rate_limit = user_info.headers['X-RateLimit-Limit'] + # rate_limit_remaining = user_info.headers['X-RateLimit-Remaining'] + # rate_limit_reset = user_info.headers['X-RateLimit-Reset'] + + # try: + # rate_limit = int(rate_limit) + # rate_limit_remaining = int(rate_limit_remaining) + # rate_limit_reset = int(rate_limit_reset) + # except (TypeError, ValueError): + # log( "Got weird rate headers from Twitter: %s %s %s" + # % (rate_limit, rate_limit_remaining, rate_limit_reset) + # ) + # else: + # reset = datetime.datetime.fromtimestamp(rate_limit_reset, tz=utc) + # reset = to_age(reset) + # log( "Twitter API calls used: %d / %d. Resets %s." + # % (rate_limit - rate_limit_remaining, rate_limit, reset) + # ) + + + if user_info.status_code == 200: + user_info = json.loads(user_info.text) + user_info['profile_image'] = user_info['image']['url'].split('?')[0] + + + else: + log("Google lookup failed with %d." % user_info.status_code) + raise Response(404) + + return user_info diff --git a/gittip/models/participant.py b/gittip/models/participant.py index 672a29f893..dad7f87a68 100644 --- a/gittip/models/participant.py +++ b/gittip/models/participant.py @@ -208,8 +208,8 @@ def change_username(self, desired_username): raise self.UsernameAlreadyTaken def get_accounts_elsewhere(self): - github_account = twitter_account = bitbucket_account = \ - bountysource_account = None + github_account = twitter_account = google_account = \ + bitbucket_account = bountysource_account = None for account in self.accounts_elsewhere.all(): if account.platform == "github": github_account = account @@ -219,10 +219,13 @@ def get_accounts_elsewhere(self): bitbucket_account = account elif account.platform == "bountysource": bountysource_account = account + elif account.platform == "google": + google_account = account else: raise self.UnknownPlatform(account.platform) return ( github_account , twitter_account + , google_account , bitbucket_account , bountysource_account ) @@ -241,7 +244,8 @@ def get_img_src(self, size=128): src = '/assets/%s/avatar-default.gif' % os.environ['__VERSION__'] - github, twitter, bitbucket, bountysource = self.get_accounts_elsewhere() + github, twitter, google, bitbucket, bountysource = \ + self.get_accounts_elsewhere() if github is not None: # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ if 'gravatar_id' in github.user_info: @@ -259,6 +263,13 @@ def get_img_src(self, size=128): # 73px(?!). src = src.replace('_normal.', '_bigger.') + elif google is not None: + #TODO: This is ugly. + try: + src = google.user_info['profile_image'] + except KeyError: + pass + return src def get_tip_to(self, tippee): diff --git a/www/%username/index.html b/www/%username/index.html index 3f67cd0b0d..297cb346ab 100644 --- a/www/%username/index.html +++ b/www/%username/index.html @@ -26,8 +26,8 @@ title = participant.username # used in the title tag username = participant.username # used in footer shared with on/$platform/ # pages -github_account, twitter_account, bitbucket_account, bountysource_account = \ - participant.get_accounts_elsewhere() +github_account, twitter_account, google_account, bitbucket_account, \ + bountysource_account = participant.get_accounts_elsewhere() long_statement = len(participant.statement) > LONG_STATEMENT communities = [c for c in community.get_list_for(participant) if c['is_member']] if participant.type == 'individual': diff --git a/www/on/google/%screen_name/index.html b/www/on/google/%screen_name/index.html new file mode 100644 index 0000000000..07cca86636 --- /dev/null +++ b/www/on/google/%screen_name/index.html @@ -0,0 +1,162 @@ +"""Google user page on Gittip. +""" +import datetime +import decimal + +import requests +from aspen import json, Response, log +from aspen.utils import to_age, utc +from gittip import AMOUNTS, CARDINALS, db +from gittip.elsewhere import google +from gittip.models import Participant +# ========================================================================== ^L + +# Try to load from Google. +# ========================= + +user_info = google.get_user_info(path['screen_name']) + +# Try to load from Gittip. +# ======================== + +username = user_info['id'] +name = user_info.get('displayName') +if not name: + name = username +url = user_info.get('url') + +account = google.GoogleAccount(user_info['id'], user_info) +participant = Participant.query.get(account.participant_id) +if account.is_claimed: + request.redirect('/%s/' % participant.id) +locked = account.is_locked +lock_action = "unlock" if account.is_locked else "lock" + +nbackers = participant.get_number_of_backers() +title = username + +# ========================================================================== ^L +{% extends templates/base.html %} + +{% block heading %}

Google

{% end %} + +{% block box %} + + + + + + + +
+ + +

{{ name }} has

+
{{ nbackers }}
+
{{ 'person' if nbackers == 1 else 'people' }} ready to give
+
+ + +{% end %} + +{% block page %} + +
+ {% if account.is_locked %} + +

{{ name }} has opted out of Gittip.

+ +

If you are {{ name }} + on Google, you can unlock your account to allow people to pledge tips to + you on Gittip.

+ + + + {% else %} + + +

{{ name }} has not joined Gittip.

+ +

Is this you? + {% if user.ANON %} + Click + here to opt in to Gittip. We never collect money for you until + you do. + {% else %} + Sign out and sign back in + to claim this account + {% end %} +

+ + {% if user.ANON %} +

What is Gittip?

+ +

Gittip is a way to thank and support your favorite artists, musicians, + writers, programmers, etc. by setting up a small weekly cash gift to them. + Read more ...

+ + +

Don't like what you see?

+ +

If you are {{ name }} you can explicitly opt out of Gittip by + locking this account. We don't allow new pledges to locked + accounts.

+ + + {% end %} + + {% end %} +
+{% end %} diff --git a/www/on/google/%screen_name/lock-fail.html b/www/on/google/%screen_name/lock-fail.html new file mode 100644 index 0000000000..360a135a91 --- /dev/null +++ b/www/on/google/%screen_name/lock-fail.html @@ -0,0 +1,18 @@ +username = path['screen_name'] +^L +{% extends templates/base.html %} +{% block heading %}

Failure

{% end %} +{% block box %} + +
+ +

Are you really {{ username }}?

+ +

Your attempt to lock or unlock this account failed because you're + logged into Twitter as someone else. Please sign out of Twitter and try again.

+ +
+ +{% end %} diff --git a/www/on/google/associate b/www/on/google/associate new file mode 100644 index 0000000000..7bf020f3a4 --- /dev/null +++ b/www/on/google/associate @@ -0,0 +1,108 @@ +"""Associate a Twitter account with a Gittip account. + +First we do the OAuth dance with Twitter. Once we've authenticated the user +against Twitter, we record them in our elsewhere table. This table contains +information for Twitter users whether or not they are explicit participants in +the Gittip community. + +""" +from urlparse import parse_qs + +import requests +from oauth_hook import OAuthHook +from aspen import log, Response, json +from aspen import resources +from gittip.elsewhere import ACTIONS, twitter +from gittip.participant import NeedConfirmation + +# ========================== ^L + +if 'denied' in qs: + request.redirect('/') + + +token = qs['oauth_token'] +try: + secret, action, then = website.oauth_cache.pop(token) +except KeyError: + request.redirect("/about/me.html") + +oauth_hook = OAuthHook(token, secret, header_auth=True) +response = requests.post( "https://api.twitter.com/oauth/access_token" + , data={"oauth_verifier": qs['oauth_verifier']} + , hooks={'pre_request': oauth_hook} + ) +assert response.status_code == 200, response.status_code + +reply = parse_qs(response.text) +token = reply['oauth_token'][0] +secret = reply['oauth_token_secret'][0] +user_id = reply['user_id'][0] + + +oauth_hook = OAuthHook(token, secret, header_auth=True) +response = requests.get( "https://api.twitter.com/1/users/show.json?user_id=%s" % user_id + , hooks={'pre_request': oauth_hook} + ) +user_info = json.loads(response.text) +assert response.status_code == 200, response.status_code + + +# Load Twitter user info. + +if action not in ACTIONS: + raise Response(400) + +# Make sure we have a Twitter screen_name. +screen_name = user_info.get('screen_name') +if screen_name is None: + log(u"We got a user_info from Twitter with no screen_name [%s, %s]" + % (action, then)) + raise Response(400) +user_info['html_url'] = "https://twitter.com/" + screen_name + +# Do something. +log(u"%s wants to %s" % (screen_name, action)) + +account = twitter.TwitterAccount(user_info['id'], user_info) + +if action == 'opt-in': # opt in + user = account.opt_in(screen_name) # set 'user' to give them a session :/ +elif action == 'connect': # connect + if user.ANON: + raise Response(404) + try: + user.take_over(account) + except NeedConfirmation, obstacles: + + # XXX Eep! Internal redirect! Really?! + request.internally_redirected_from = request.fs + request.fs = website.www_root + '/on/confirm.html' + request.resource = resources.get(request) + + raise request.resource.respond(request) +else: # lock or unlock + if then != screen_name: + + # The user could spoof `then' to match their screen_name, but the most + # they can do is lock/unlock their own Twitter account in a convoluted + # way. + + then = u'/on/twitter/%s/lock-fail.html' % then + + else: + + # Associate the Twitter screen_name with a randomly-named, unclaimed + # Gittip participant. + + assert account.participant_id != screen_name, screen_name # sanity check + account.set_is_locked(action == 'lock') + +if then == u'': + then = u'/%s/' % account.participant_id +if not then.startswith(u'/'): + # Interpret it as a Twitter screen_name. + then = u'/on/twitter/%s/' % then +request.redirect(then) + +# ========================== ^L text/plain diff --git a/www/on/google/redirect b/www/on/google/redirect new file mode 100644 index 0000000000..fba9be4658 --- /dev/null +++ b/www/on/google/redirect @@ -0,0 +1,39 @@ +"""Part of Twitter oauth. + +From here we redirect users to Twitter after storing needed info in an +in-memory cache. We get them again at www/on/twitter/associate. + +""" +from urlparse import parse_qs + +import requests +from oauth_hook import OAuthHook + +OAuthHook.consumer_key = website.twitter_consumer_key +OAuthHook.consumer_secret = website.twitter_consumer_secret + +website.oauth_cache = {} # XXX What happens to someone who was half-authed + # when we bounced the server? + +# ========================== ^L + +oauth_hook = OAuthHook(header_auth=True) +response = requests.post( "https://api.twitter.com/oauth/request_token" + , hooks={'pre_request': oauth_hook} + ) + +assert response.status_code == 200, response.status_code # safety check + +reply = parse_qs(response.text) + +token = reply['oauth_token'][0] +secret = reply['oauth_token_secret'][0] +assert reply['oauth_callback_confirmed'][0] == "true" # sanity check + +action = qs.get('action', 'opt-in') +then = qs.get('then', '') +website.oauth_cache[token] = (secret, action, then) + +url = "https://api.twitter.com/oauth/authenticate?oauth_token=%s" +request.redirect(url % token) +# ========================== ^L text/plain From 783b56ddd0088fe3ab30571e912d363e985ddf44 Mon Sep 17 00:00:00 2001 From: Lyndsy Simon Date: Mon, 18 Mar 2013 14:22:57 -0700 Subject: [PATCH 002/101] WIP - Abstracting into a more generic architecture --- configure-aspen.py | 5 + gittip/elsewhere/__init__.py | 79 +++++++++++++ gittip/elsewhere/google.py | 91 ++++++++++++--- gittip/wireup.py | 6 + .../%username}/index.html | 81 +++++++------ www/on/%service/associate | 12 ++ www/on/%service/redirect | 21 ++++ www/on/google/%screen_name/lock-fail.html | 18 --- www/on/google/associate | 108 ------------------ www/on/google/redirect | 39 ------- 10 files changed, 246 insertions(+), 214 deletions(-) rename www/on/{google/%screen_name => %service/%username}/index.html (66%) create mode 100644 www/on/%service/associate create mode 100644 www/on/%service/redirect delete mode 100644 www/on/google/%screen_name/lock-fail.html delete mode 100644 www/on/google/associate delete mode 100644 www/on/google/redirect diff --git a/configure-aspen.py b/configure-aspen.py index 26269ba54d..1982351d1d 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -16,6 +16,7 @@ gittip.wireup.mixpanel(website) gittip.wireup.nanswers() gittip.wireup.nmembers(website) +gittip.wireup.elsewhere_providers(website) website.bitbucket_consumer_key = os.environ['BITBUCKET_CONSUMER_KEY'].decode('ASCII') @@ -37,6 +38,10 @@ website.bountysource_api_secret = os.environ['BOUNTYSOURCE_API_SECRET'].decode('ASCII') website.bountysource_callback = os.environ['BOUNTYSOURCE_CALLBACK'].decode('ASCII') +website.google_client_id = os.environ['GOOGLE_CLIENT_ID'].decode('ASCII') +website.google_client_secret = os.environ['GOOGLE_CLIENT_SECRET'].decode('ASCII') +website.google_callback = os.environ['GOOGLE_CALLBACK'].decode('ASCII') + website.hooks.inbound_early += [ gittip.canonize , gittip.configure_payments , gittip.csrf.inbound diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index d2f3e630c8..eface34268 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -9,6 +9,8 @@ ACTIONS = [u'opt-in', u'connect', u'lock', u'unlock'] +class AuthorizationFailure(Exception): + pass def _resolve(platform, username_key, username): """Given three unicodes, return a username. @@ -30,6 +32,83 @@ def _resolve(platform, username_key, username): ) return rec['participant'] +class ServiceElsewhere(object): + + def __init__(self, website=None, username=None): + self.username = username + self.website = website + if username: + self._get_user_info() + + @property + def external_auth_url(self): + ''' + Returns the URL to which the user should be directed to begin the OAuth + handshake. + + If conditions must be met prior to the user being redirected, they + should be set up here. Example: Twitter requires a unique request token + for each handshake. The initial call to Twitter to get that token + should be implmented here. + + ''' + raise NotImplementedError() + + @property + def external_profile_url(self): + ''' + Returns a user's profile on the external platform, if such a beast + exists. + + ''' + raise NotImplementedError() + + @property + def get_user_info(self, user_id=None, participant=None): + ''' + Returns a dict containing the user's details on the external service. + The following keys are required: + + `user_id` + The ID of the user on the external service + `token` + The long-term token used to access user data + + :param user_id: + The ID of the participant, on the external service + :param participant: + The participant ID + + Note that overriding methods should handle the case where neither params + are provided by raising an appropriate excpetion. + + ''' + raise NotImplementedError() + + _display_name = None + + @property + def display_name(self): + ''' + For most services, the name displayed should be `self.username`. For + Twitter, this would be the user's screen name. Other services may have + different user-facing strings - like Google, which uses email addresses. + To make this a bit more complex, the Google's email addresses are + mutable. + + This method should be overridden only if the immutable ID from the + service provider is not suitable to be displayed back to the user. + ''' + return self._display_name or self.username + + @display_name.setter + def display_name(self, val): + self._display_name = val + + def get_oauth_init_url(self): + raise NotImplementedError() + + class AccountElsewhere(object): diff --git a/gittip/elsewhere/google.py b/gittip/elsewhere/google.py index cdad48e6ee..09e17d739f 100644 --- a/gittip/elsewhere/google.py +++ b/gittip/elsewhere/google.py @@ -1,10 +1,83 @@ import datetime import gittip +import hashlib import requests from aspen import json, log, Response from aspen.utils import to_age, utc, typecheck from aspen.website import Website -from gittip.elsewhere import AccountElsewhere, ACTIONS, _resolve +from gittip.elsewhere import AccountElsewhere, ACTIONS, _resolve, ServiceElsewhere, AuthorizationFailure + + +class GoogleProvider(ServiceElsewhere): + service_name = 'google' + oauth_cache = {} + + def get_oauth_init_url(self, next='', action=u'opt-in'): + nonce = hashlib.md5(datetime.datetime.now().isoformat()).hexdigest() + + state = ','.join((self.username, nonce, action)) + + self.oauth_cache[self.username] = nonce + + return ''.join([ + "https://accounts.google.com/o/oauth2/auth", + "?response_type=code", + "&client_id=%s", + "&redirect_uri=%s", + "&state=%s", + "&scope=https://www.googleapis.com/auth/userinfo.profile", + ]) % (self.website.google_client_id, next or 'associate', state) + + def handle_oauth_callback(self, qs): + # pull info out of the querystring + username, nonce, action = qs['state'].split(',') + + # Make sure the nonces match our cache + if nonce != self.oauth_cache.get(username): #TODO: Make this a pop + raise AuthorizationFailed('Nonces do not match.') + + if action == u'opt-in': + log('opt-in detected') + + return True + + + + + + + def _get_user_info(self): + typecheck(self.username, unicode) + + # Check to see if we've already imported these details + rec = gittip.db.fetchone( "SELECT user_info FROM elsewhere " + "WHERE platform='google' " + "AND user_info->'screen_name' = %s" + , (self.username,) + ) + if rec: + # Use the record we have + user_info = rec['user_info'] + else: + # Call the service's API + url = 'https://www.googleapis.com/plus/v1/people/%s?key=AIzaSyDFwxAtyIPi08FgI58rMsL5A9CqvL3kOaY' + response = requests.get(url % self.username) + + # Make sure we got back a valid response + if response.status_code != 200: + log("Google user lookup failed with %d." % user_info.status_code) + raise Response(404) + + + external_user = json.loads(response.text) + self._user_info = external_user + + # Get the user's avatar URL on the outside service. + # Google's includes a ?sz=50 arg, which makes it really small. + # We strip that out. + self.avatar = external_user['image']['url'].split('?')[0] + self.display_name = external_user['displayName'] + class GoogleAccount(AccountElsewhere): @@ -18,23 +91,7 @@ def resolve(screen_name): return _resolve(u'google', u'screen_name', screen_name) -def oauth_url(website, action, then=""): - """Return a URL to start oauth dancing with Google. - - """ - typecheck(website, Website, action, unicode, then, unicode) - assert action in ACTIONS - - # Pack action,then into data and base64-encode. Querystring isn't - # available because it's consumed by the initial GitHub request. - - data = u'%s,%s' % (action, then) - data = data.encode('UTF-8').encode('base64').decode('US-ASCII') - - url = u'https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=%s&redirect_uri=%s&state=%s' - url %= (website.google_client_id, website.google_callback, data) - return url def get_user_info(screen_name): diff --git a/gittip/wireup.py b/gittip/wireup.py index 3e6388d450..b471dd8d4b 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -10,6 +10,7 @@ import psycopg2 import stripe import gittip.mixpanel +from gittip.elsewhere.google import GoogleProvider from gittip.postgres import PostgresManager from psycopg2.extensions import cursor as RegularCursor @@ -74,3 +75,8 @@ def nmembers(website): from gittip.models import community community.NMEMBERS_THRESHOLD = int(os.environ['NMEMBERS_THRESHOLD']) website.NMEMBERS_THRESHOLD = community.NMEMBERS_THRESHOLD + +def elsewhere_providers(website): + website.elsewhere = { + 'google': GoogleProvider + } diff --git a/www/on/google/%screen_name/index.html b/www/on/%service/%username/index.html similarity index 66% rename from www/on/google/%screen_name/index.html rename to www/on/%service/%username/index.html index 07cca86636..bf5a62bef4 100644 --- a/www/on/google/%screen_name/index.html +++ b/www/on/%service/%username/index.html @@ -1,4 +1,4 @@ -"""Google user page on Gittip. +"""External user page on Gittip. """ import datetime import decimal @@ -7,48 +7,65 @@ from aspen import json, Response, log from aspen.utils import to_age, utc from gittip import AMOUNTS, CARDINALS, db -from gittip.elsewhere import google from gittip.models import Participant +from gittip.elsewhere import github # ========================================================================== ^L -# Try to load from Google. -# ========================= +# # Try to load from Google. +# # ========================= -user_info = google.get_user_info(path['screen_name']) +# user_info = google.get_user_info(path['screen_name']) -# Try to load from Gittip. -# ======================== +# # Try to load from Gittip. +# # ======================== -username = user_info['id'] -name = user_info.get('displayName') -if not name: - name = username -url = user_info.get('url') +# username = user_info['id'] +# name = user_info.get('displayName') +# if not name: +# name = username +# url = user_info.get('url') -account = google.GoogleAccount(user_info['id'], user_info) -participant = Participant.query.get(account.participant_id) -if account.is_claimed: - request.redirect('/%s/' % participant.id) -locked = account.is_locked -lock_action = "unlock" if account.is_locked else "lock" +# account = google.GoogleAccount(user_info['id'], user_info) +# participant = Participant.query.get(account.participant_id) +# if account.is_claimed: +# request.redirect('/%s/' % participant.id) +# locked = account.is_locked +# lock_action = "unlock" if account.is_locked else "lock" + +# nbackers = participant.get_number_of_backers() +# title = username + +service = website.elsewhere[path['service']](website, path['username']) + +url = '' + +nbackers = 0 + + +class account(object): + is_locked = False + username = 'External Username' #Chad's directive + oauth_url = 'oauth.url' + profile_url = 'profile.url' + service_name = 'External Service' + +title = account.service_name -nbackers = participant.get_number_of_backers() -title = username # ========================================================================== ^L {% extends templates/base.html %} -{% block heading %}

Google

{% end %} +{% block heading %}

{{ account.service_name }}

{% end %} {% block box %} @@ -71,9 +88,9 @@

Your Weekly Gift

{% end %}
Sign in using - Google or + Google or GitHub - to pledge to {{ name }}.
+ to pledge to {{ service.display_name }}. {% else %} @@ -112,13 +129,13 @@

Your Weekly Pledge

{% if account.is_locked %} -

{{ name }} has opted out of Gittip.

+

{{ service.display_name }} has opted out of Gittip.

-

If you are {{ name }} +

If you are {{ service.display_name }} on Google, you can unlock your account to allow people to pledge tips to you on Gittip.

- {% else %} @@ -126,11 +143,11 @@

{{ name }} has opted out of Gittip.

$(document).ready(Gittip.initTipButtons); -

{{ name }} has not joined Gittip.

+

{{ service.display_name }} has not joined Gittip.

Is this you? {% if user.ANON %} - Click + Click here to opt in to Gittip. We never collect money for you until you do. {% else %} @@ -149,11 +166,11 @@

What is Gittip?

Don't like what you see?

-

If you are {{ name }} you can explicitly opt out of Gittip by +

If you are {{ service.display_name }} you can explicitly opt out of Gittip by locking this account. We don't allow new pledges to locked accounts.

- {% end %} diff --git a/www/on/%service/associate b/www/on/%service/associate new file mode 100644 index 0000000000..c2eab82562 --- /dev/null +++ b/www/on/%service/associate @@ -0,0 +1,12 @@ + +# ========================== ^L + +service = website.elsewhere[path['service']](website, username) + +service.handle_oauth_callback(qs) + +# ========================== ^L text/plain + +Caught! + +{{ qs['code'] }} \ No newline at end of file diff --git a/www/on/%service/redirect b/www/on/%service/redirect new file mode 100644 index 0000000000..0b61591ab6 --- /dev/null +++ b/www/on/%service/redirect @@ -0,0 +1,21 @@ +import requests +import os + + +# ========================== ^L + +service = website.elsewhere[path['service']](website, qs.get('username')) + +host = os.getenv('CANONICAL_HOST') +scheme = os.getenv('CANONICAL_SCHEME') + +redirect_uri = '%s://%s%s' % ( + scheme, + host, + '/on/%s/associate' % service.service_name +) + +request.redirect(service.get_oauth_init_url(redirect_uri)) + + +# ========================== ^L text/plain diff --git a/www/on/google/%screen_name/lock-fail.html b/www/on/google/%screen_name/lock-fail.html deleted file mode 100644 index 360a135a91..0000000000 --- a/www/on/google/%screen_name/lock-fail.html +++ /dev/null @@ -1,18 +0,0 @@ -username = path['screen_name'] -^L -{% extends templates/base.html %} -{% block heading %}

Failure

{% end %} -{% block box %} - -
- -

Are you really {{ username }}?

- -

Your attempt to lock or unlock this account failed because you're - logged into Twitter as someone else. Please sign out of Twitter and try again.

- -
- -{% end %} diff --git a/www/on/google/associate b/www/on/google/associate deleted file mode 100644 index 7bf020f3a4..0000000000 --- a/www/on/google/associate +++ /dev/null @@ -1,108 +0,0 @@ -"""Associate a Twitter account with a Gittip account. - -First we do the OAuth dance with Twitter. Once we've authenticated the user -against Twitter, we record them in our elsewhere table. This table contains -information for Twitter users whether or not they are explicit participants in -the Gittip community. - -""" -from urlparse import parse_qs - -import requests -from oauth_hook import OAuthHook -from aspen import log, Response, json -from aspen import resources -from gittip.elsewhere import ACTIONS, twitter -from gittip.participant import NeedConfirmation - -# ========================== ^L - -if 'denied' in qs: - request.redirect('/') - - -token = qs['oauth_token'] -try: - secret, action, then = website.oauth_cache.pop(token) -except KeyError: - request.redirect("/about/me.html") - -oauth_hook = OAuthHook(token, secret, header_auth=True) -response = requests.post( "https://api.twitter.com/oauth/access_token" - , data={"oauth_verifier": qs['oauth_verifier']} - , hooks={'pre_request': oauth_hook} - ) -assert response.status_code == 200, response.status_code - -reply = parse_qs(response.text) -token = reply['oauth_token'][0] -secret = reply['oauth_token_secret'][0] -user_id = reply['user_id'][0] - - -oauth_hook = OAuthHook(token, secret, header_auth=True) -response = requests.get( "https://api.twitter.com/1/users/show.json?user_id=%s" % user_id - , hooks={'pre_request': oauth_hook} - ) -user_info = json.loads(response.text) -assert response.status_code == 200, response.status_code - - -# Load Twitter user info. - -if action not in ACTIONS: - raise Response(400) - -# Make sure we have a Twitter screen_name. -screen_name = user_info.get('screen_name') -if screen_name is None: - log(u"We got a user_info from Twitter with no screen_name [%s, %s]" - % (action, then)) - raise Response(400) -user_info['html_url'] = "https://twitter.com/" + screen_name - -# Do something. -log(u"%s wants to %s" % (screen_name, action)) - -account = twitter.TwitterAccount(user_info['id'], user_info) - -if action == 'opt-in': # opt in - user = account.opt_in(screen_name) # set 'user' to give them a session :/ -elif action == 'connect': # connect - if user.ANON: - raise Response(404) - try: - user.take_over(account) - except NeedConfirmation, obstacles: - - # XXX Eep! Internal redirect! Really?! - request.internally_redirected_from = request.fs - request.fs = website.www_root + '/on/confirm.html' - request.resource = resources.get(request) - - raise request.resource.respond(request) -else: # lock or unlock - if then != screen_name: - - # The user could spoof `then' to match their screen_name, but the most - # they can do is lock/unlock their own Twitter account in a convoluted - # way. - - then = u'/on/twitter/%s/lock-fail.html' % then - - else: - - # Associate the Twitter screen_name with a randomly-named, unclaimed - # Gittip participant. - - assert account.participant_id != screen_name, screen_name # sanity check - account.set_is_locked(action == 'lock') - -if then == u'': - then = u'/%s/' % account.participant_id -if not then.startswith(u'/'): - # Interpret it as a Twitter screen_name. - then = u'/on/twitter/%s/' % then -request.redirect(then) - -# ========================== ^L text/plain diff --git a/www/on/google/redirect b/www/on/google/redirect deleted file mode 100644 index fba9be4658..0000000000 --- a/www/on/google/redirect +++ /dev/null @@ -1,39 +0,0 @@ -"""Part of Twitter oauth. - -From here we redirect users to Twitter after storing needed info in an -in-memory cache. We get them again at www/on/twitter/associate. - -""" -from urlparse import parse_qs - -import requests -from oauth_hook import OAuthHook - -OAuthHook.consumer_key = website.twitter_consumer_key -OAuthHook.consumer_secret = website.twitter_consumer_secret - -website.oauth_cache = {} # XXX What happens to someone who was half-authed - # when we bounced the server? - -# ========================== ^L - -oauth_hook = OAuthHook(header_auth=True) -response = requests.post( "https://api.twitter.com/oauth/request_token" - , hooks={'pre_request': oauth_hook} - ) - -assert response.status_code == 200, response.status_code # safety check - -reply = parse_qs(response.text) - -token = reply['oauth_token'][0] -secret = reply['oauth_token_secret'][0] -assert reply['oauth_callback_confirmed'][0] == "true" # sanity check - -action = qs.get('action', 'opt-in') -then = qs.get('then', '') -website.oauth_cache[token] = (secret, action, then) - -url = "https://api.twitter.com/oauth/authenticate?oauth_token=%s" -request.redirect(url % token) -# ========================== ^L text/plain From 2b0a7c0d3162d14ff9c2e658f18fcb8cee14a994 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 10:57:08 -0400 Subject: [PATCH 003/101] Post-merge get_accounts_elsewhere I should've done this on the last commit but neglected to. --- gittip/models/_mixin_elsewhere.py | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/gittip/models/_mixin_elsewhere.py b/gittip/models/_mixin_elsewhere.py index d80a363c17..94a281e24f 100644 --- a/gittip/models/_mixin_elsewhere.py +++ b/gittip/models/_mixin_elsewhere.py @@ -53,6 +53,7 @@ def get_accounts_elsewhere(self): """ github_account = None twitter_account = None + google_account = None bitbucket_account = None bountysource_account = None @@ -64,6 +65,8 @@ def get_accounts_elsewhere(self): github_account = account elif account.platform == "twitter": twitter_account = account + elif account.platform == "google": + google_account = account elif account.platform == "bitbucket": bitbucket_account = account elif account.platform == "bountysource": @@ -71,28 +74,6 @@ def get_accounts_elsewhere(self): else: raise UnknownPlatform(account.platform) - return ( github_account - , twitter_account - , bitbucket_account - , bountysource_account - ) - - def get_accounts_elsewhere(self): - github_account = twitter_account = google_account = \ - bitbucket_account = bountysource_account = None - for account in self.accounts_elsewhere.all(): - if account.platform == "github": - github_account = account - elif account.platform == "twitter": - twitter_account = account - elif account.platform == "bitbucket": - bitbucket_account = account - elif account.platform == "bountysource": - bountysource_account = account - elif account.platform == "google": - google_account = account - else: - raise self.UnknownPlatform(account.platform) return ( github_account , twitter_account , google_account From 657cca2215424afcaa88bc9108958ac75258721d Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 10:58:57 -0400 Subject: [PATCH 004/101] Clean up wireup as well Again, should have been done in the merge. --- gittip/wireup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gittip/wireup.py b/gittip/wireup.py index 5f358dc1d6..c1192dab92 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -79,12 +79,11 @@ def nmembers(website): community.NMEMBERS_THRESHOLD = int(os.environ['NMEMBERS_THRESHOLD']) website.NMEMBERS_THRESHOLD = community.NMEMBERS_THRESHOLD -<<<<<<< HEAD def elsewhere_providers(website): website.elsewhere = { 'google': GoogleProvider } -======= + def envvars(website): missing_keys = [] @@ -149,4 +148,3 @@ def is_yesish(val): aspen.log_dammit("=" * 42) raise SystemExit ->>>>>>> master From 3393dd987e8d34cbc37e79e9ba1630ed964e3d47 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 12:56:37 -0400 Subject: [PATCH 005/101] Move Google config out into wireup --- configure-aspen.py | 4 ---- default_local.env | 3 +++ gittip/wireup.py | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/configure-aspen.py b/configure-aspen.py index 8f2852a593..72fb457023 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -40,10 +40,6 @@ def up_minthreads(website): website.hooks.startup.insert(0, up_minthreads) -website.google_client_id = os.environ['GOOGLE_CLIENT_ID'].decode('ASCII') -website.google_client_secret = os.environ['GOOGLE_CLIENT_SECRET'].decode('ASCII') -website.google_callback = os.environ['GOOGLE_CALLBACK'].decode('ASCII') - website.hooks.inbound_early += [ gittip.canonize , gittip.configure_payments , gittip.security.authentication.inbound diff --git a/default_local.env b/default_local.env index 99575d672a..27be8a0da4 100644 --- a/default_local.env +++ b/default_local.env @@ -21,6 +21,9 @@ TWITTER_CONSUMER_SECRET=mUymh1hVMiQdMQbduQFYRi79EYYVeOZGrhj27H59H78 TWITTER_ACCESS_TOKEN=34175404-G6W8Hh19GWuUhIMEXK0LyZsy7N9aCMcy1bYJ9rI TWITTER_ACCESS_TOKEN_SECRET=K6wxV1OCsihZAkEPkWtoLYDiRJnWajBBWn4UgliTRQ TWITTER_CALLBACK=http://127.0.0.1:8537/on/twitter/associate +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK= MIXPANEL_TOKEN=cb9dec68ac0ee57071f0be39f164a417 NANSWERS_THRESHOLD=2 NMEMBERS_THRESHOLD=50 diff --git a/gittip/wireup.py b/gittip/wireup.py index c1192dab92..b09ae2b9f0 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -111,6 +111,10 @@ def is_yesish(val): website.twitter_access_token_secret = envvar('TWITTER_ACCESS_TOKEN_SECRET') website.twitter_callback = envvar('TWITTER_CALLBACK') + website.google_client_id = envvar('GOOGLE_CLIENT_ID') + website.google_client_secret = envvar('GOOGLE_CLIENT_SECRET') + website.google_callback = envvar('GOOGLE_CALLBACK') + website.bountysource_www_host = envvar('BOUNTYSOURCE_WWW_HOST') website.bountysource_api_host = envvar('BOUNTYSOURCE_API_HOST') website.bountysource_api_secret = envvar('BOUNTYSOURCE_API_SECRET') From f2e93e0e4339fd172278c8d7febe8c254aa567d5 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 13:05:34 -0400 Subject: [PATCH 006/101] Get old accounts elsewhere barely working TTW --- .../%username/{index.html => index.html.spt} | 60 +++---------------- 1 file changed, 7 insertions(+), 53 deletions(-) rename www/on/%service/%username/{index.html => index.html.spt} (59%) diff --git a/www/on/%service/%username/index.html b/www/on/%service/%username/index.html.spt similarity index 59% rename from www/on/%service/%username/index.html rename to www/on/%service/%username/index.html.spt index bf5a62bef4..3823b17f13 100644 --- a/www/on/%service/%username/index.html +++ b/www/on/%service/%username/index.html.spt @@ -6,10 +6,11 @@ import requests from aspen import json, Response, log from aspen.utils import to_age, utc -from gittip import AMOUNTS, CARDINALS, db -from gittip.models import Participant +from gittip import CARDINALS, db +from gittip.models.participant import Participant from gittip.elsewhere import github -# ========================================================================== ^L + +[-----------------------------------------------------------------------------] # # Try to load from Google. # # ========================= @@ -52,7 +53,7 @@ title = account.service_name -# ========================================================================== ^L +[-----------------------------------------------------------------------------] {% extends templates/base.html %} {% block heading %}

{{ account.service_name }}

{% end %} @@ -65,7 +66,7 @@
@@ -73,55 +74,8 @@

{{ service.display_name }} has

- + -

{{ name }} has

+

{{ service.display_name }} has

{{ nbackers }}
{{ 'person' if nbackers == 1 else 'people' }} ready to give
-

{{ service.display_name }} has

+

{{ escape(service.display_name) }} has

{{ nbackers }}
{{ 'person' if nbackers == 1 else 'people' }} ready to give
- {% end %} {% block page %} From 642ae09d0cebb4c1ada639dfc642acac915fff93 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 13:14:31 -0400 Subject: [PATCH 007/101] Modernize a db API call --- gittip/elsewhere/google.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gittip/elsewhere/google.py b/gittip/elsewhere/google.py index 09e17d739f..b558dcd8b6 100644 --- a/gittip/elsewhere/google.py +++ b/gittip/elsewhere/google.py @@ -50,11 +50,11 @@ def _get_user_info(self): typecheck(self.username, unicode) # Check to see if we've already imported these details - rec = gittip.db.fetchone( "SELECT user_info FROM elsewhere " - "WHERE platform='google' " - "AND user_info->'screen_name' = %s" - , (self.username,) - ) + rec = gittip.db.one( "SELECT user_info FROM elsewhere " + "WHERE platform='google' " + "AND user_info->'screen_name' = %s" + , (self.username,) + ) if rec: # Use the record we have user_info = rec['user_info'] From 06c434210437630ef1430917ebc8b37a1dadb413 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 13:18:58 -0400 Subject: [PATCH 008/101] Rip out Google auth from elsewhere branch When LyndsySimon started on the accounts elsewhere refactor (#1369) he also started adding support for Google accounts as well. I've made a "google" branch to keep that code around, I'm ripping it out of the elsewhere branch because our first goal needs to be to refactor accounts elsewhere without any user-visible changes. --- gittip/elsewhere/google.py | 145 ----------------------- gittip/models/_mixin_elsewhere.py | 13 +- gittip/wireup.py | 9 +- www/%username/index.html.spt | 2 +- www/on/%service/%username/index.html.spt | 24 ---- 5 files changed, 3 insertions(+), 190 deletions(-) delete mode 100644 gittip/elsewhere/google.py diff --git a/gittip/elsewhere/google.py b/gittip/elsewhere/google.py deleted file mode 100644 index b558dcd8b6..0000000000 --- a/gittip/elsewhere/google.py +++ /dev/null @@ -1,145 +0,0 @@ -import datetime -import gittip -import hashlib -import requests -from aspen import json, log, Response -from aspen.utils import to_age, utc, typecheck -from aspen.website import Website -from gittip.elsewhere import AccountElsewhere, ACTIONS, _resolve, ServiceElsewhere, AuthorizationFailure - - -class GoogleProvider(ServiceElsewhere): - service_name = 'google' - oauth_cache = {} - - def get_oauth_init_url(self, next='', action=u'opt-in'): - nonce = hashlib.md5(datetime.datetime.now().isoformat()).hexdigest() - - state = ','.join((self.username, nonce, action)) - - self.oauth_cache[self.username] = nonce - - return ''.join([ - "https://accounts.google.com/o/oauth2/auth", - "?response_type=code", - "&client_id=%s", - "&redirect_uri=%s", - "&state=%s", - "&scope=https://www.googleapis.com/auth/userinfo.profile", - ]) % (self.website.google_client_id, next or 'associate', state) - - def handle_oauth_callback(self, qs): - # pull info out of the querystring - username, nonce, action = qs['state'].split(',') - - # Make sure the nonces match our cache - if nonce != self.oauth_cache.get(username): #TODO: Make this a pop - raise AuthorizationFailed('Nonces do not match.') - - if action == u'opt-in': - log('opt-in detected') - - return True - - - - - - - def _get_user_info(self): - typecheck(self.username, unicode) - - # Check to see if we've already imported these details - rec = gittip.db.one( "SELECT user_info FROM elsewhere " - "WHERE platform='google' " - "AND user_info->'screen_name' = %s" - , (self.username,) - ) - if rec: - # Use the record we have - user_info = rec['user_info'] - else: - # Call the service's API - url = 'https://www.googleapis.com/plus/v1/people/%s?key=AIzaSyDFwxAtyIPi08FgI58rMsL5A9CqvL3kOaY' - response = requests.get(url % self.username) - - # Make sure we got back a valid response - if response.status_code != 200: - log("Google user lookup failed with %d." % user_info.status_code) - raise Response(404) - - - external_user = json.loads(response.text) - self._user_info = external_user - - # Get the user's avatar URL on the outside service. - # Google's includes a ?sz=50 arg, which makes it really small. - # We strip that out. - self.avatar = external_user['image']['url'].split('?')[0] - self.display_name = external_user['displayName'] - - - -class GoogleAccount(AccountElsewhere): - platform = u'google' - - def get_url(self): - return "https://plus.google.com/" + self.user_info['screen_name'] - - -def resolve(screen_name): - return _resolve(u'google', u'screen_name', screen_name) - - - - - -def get_user_info(screen_name): - """Given a unicode, return a dict. - """ - typecheck(screen_name, unicode) - rec = gittip.db.fetchone( "SELECT user_info FROM elsewhere " - "WHERE platform='google' " - "AND user_info->'screen_name' = %s" - , (screen_name,) - ) - if rec is not None: - user_info = rec['user_info'] - else: - url = "https://www.googleapis.com/plus/v1/people/%s?key=AIzaSyDFwxAtyIPi08FgI58rMsL5A9CqvL3kOaY" - user_info = requests.get(url % screen_name) - - - # Keep an eye on our API usage. - # ================================= - - # rate_limit = user_info.headers['X-RateLimit-Limit'] - # rate_limit_remaining = user_info.headers['X-RateLimit-Remaining'] - # rate_limit_reset = user_info.headers['X-RateLimit-Reset'] - - # try: - # rate_limit = int(rate_limit) - # rate_limit_remaining = int(rate_limit_remaining) - # rate_limit_reset = int(rate_limit_reset) - # except (TypeError, ValueError): - # log( "Got weird rate headers from Twitter: %s %s %s" - # % (rate_limit, rate_limit_remaining, rate_limit_reset) - # ) - # else: - # reset = datetime.datetime.fromtimestamp(rate_limit_reset, tz=utc) - # reset = to_age(reset) - # log( "Twitter API calls used: %d / %d. Resets %s." - # % (rate_limit - rate_limit_remaining, rate_limit, reset) - # ) - - - if user_info.status_code == 200: - user_info = json.loads(user_info.text) - user_info['profile_image'] = user_info['image']['url'].split('?')[0] - - - else: - log("Google lookup failed with %d." % user_info.status_code) - raise Response(404) - - return user_info diff --git a/gittip/models/_mixin_elsewhere.py b/gittip/models/_mixin_elsewhere.py index 94a281e24f..067b06c2fc 100644 --- a/gittip/models/_mixin_elsewhere.py +++ b/gittip/models/_mixin_elsewhere.py @@ -53,7 +53,6 @@ def get_accounts_elsewhere(self): """ github_account = None twitter_account = None - google_account = None bitbucket_account = None bountysource_account = None @@ -65,8 +64,6 @@ def get_accounts_elsewhere(self): github_account = account elif account.platform == "twitter": twitter_account = account - elif account.platform == "google": - google_account = account elif account.platform == "bitbucket": bitbucket_account = account elif account.platform == "bountysource": @@ -76,7 +73,6 @@ def get_accounts_elsewhere(self): return ( github_account , twitter_account - , google_account , bitbucket_account , bountysource_account ) @@ -96,7 +92,7 @@ def get_img_src(self, size=128): src = '/assets/%s/avatar-default.gif' % os.environ['__VERSION__'] - github, twitter, google, bitbucket, bountysource = \ + github, twitter, bitbucket, bountysource = \ self.get_accounts_elsewhere() if github is not None: # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ @@ -117,13 +113,6 @@ def get_img_src(self, size=128): src = src.replace('_normal.', '.') - elif google is not None: - #TODO: This is ugly. - try: - src = google.user_info['profile_image'] - except KeyError: - pass - return src diff --git a/gittip/wireup.py b/gittip/wireup.py index b09ae2b9f0..0ae32f933b 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -10,7 +10,6 @@ import psycopg2 import stripe import gittip.utils.mixpanel -from gittip.elsewhere.google import GoogleProvider from gittip.models.community import Community from gittip.models.participant import Participant from postgres import Postgres @@ -80,9 +79,7 @@ def nmembers(website): website.NMEMBERS_THRESHOLD = community.NMEMBERS_THRESHOLD def elsewhere_providers(website): - website.elsewhere = { - 'google': GoogleProvider - } + website.elsewhere = {} def envvars(website): @@ -111,10 +108,6 @@ def is_yesish(val): website.twitter_access_token_secret = envvar('TWITTER_ACCESS_TOKEN_SECRET') website.twitter_callback = envvar('TWITTER_CALLBACK') - website.google_client_id = envvar('GOOGLE_CLIENT_ID') - website.google_client_secret = envvar('GOOGLE_CLIENT_SECRET') - website.google_callback = envvar('GOOGLE_CALLBACK') - website.bountysource_www_host = envvar('BOUNTYSOURCE_WWW_HOST') website.bountysource_api_host = envvar('BOUNTYSOURCE_API_HOST') website.bountysource_api_secret = envvar('BOUNTYSOURCE_API_SECRET') diff --git a/www/%username/index.html.spt b/www/%username/index.html.spt index bf65c9efe1..313d738a6e 100644 --- a/www/%username/index.html.spt +++ b/www/%username/index.html.spt @@ -25,7 +25,7 @@ hero = "Profile" title = participant.username # used in the title tag username = participant.username # used in footer shared with on/$platform/ # pages -github_account, twitter_account, google_account, bitbucket_account, \ +github_account, twitter_account, bitbucket_account, \ bountysource_account = participant.get_accounts_elsewhere() long_statement = len(participant.statement) > LONG_STATEMENT communities = [c for c in community.get_list_for(username) if c.is_member] diff --git a/www/on/%service/%username/index.html.spt b/www/on/%service/%username/index.html.spt index 3823b17f13..090e380b88 100644 --- a/www/on/%service/%username/index.html.spt +++ b/www/on/%service/%username/index.html.spt @@ -12,30 +12,6 @@ from gittip.elsewhere import github [-----------------------------------------------------------------------------] -# # Try to load from Google. -# # ========================= - -# user_info = google.get_user_info(path['screen_name']) - -# # Try to load from Gittip. -# # ======================== - -# username = user_info['id'] -# name = user_info.get('displayName') -# if not name: -# name = username -# url = user_info.get('url') - -# account = google.GoogleAccount(user_info['id'], user_info) -# participant = Participant.query.get(account.participant_id) -# if account.is_claimed: -# request.redirect('/%s/' % participant.id) -# locked = account.is_locked -# lock_action = "unlock" if account.is_locked else "lock" - -# nbackers = participant.get_number_of_backers() -# title = username - service = website.elsewhere[path['service']](website, path['username']) url = '' From ffd7c1315798eb14f53005eeda29de4647aece91 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 13:44:16 -0400 Subject: [PATCH 009/101] Trim up some imports on elsewhere page --- www/on/%service/%username/index.html.spt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/www/on/%service/%username/index.html.spt b/www/on/%service/%username/index.html.spt index 090e380b88..c16fbeade2 100644 --- a/www/on/%service/%username/index.html.spt +++ b/www/on/%service/%username/index.html.spt @@ -3,13 +3,10 @@ import datetime import decimal -import requests from aspen import json, Response, log from aspen.utils import to_age, utc from gittip import CARDINALS, db from gittip.models.participant import Participant -from gittip.elsewhere import github - [-----------------------------------------------------------------------------] service = website.elsewhere[path['service']](website, path['username']) @@ -61,9 +58,10 @@ title = account.service_name

{{ service.display_name }} has opted out of Gittip.

-

If you are {{ service.display_name }} - on Google, you can unlock your account to allow people to pledge tips to - you on Gittip.

+

If you are {{ service.display_name }} + on {{ account.service_name }}, you can unlock your account to allow people + to pledge tips to you on Gittip.

From 6356e13d3a539a044226888ad702c2dd7a3ff407 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Tue, 1 Oct 2013 14:58:26 -0400 Subject: [PATCH 010/101] Clean up elsewhere page some more HTML-escape some things and fix up API for oauth URLs. --- www/on/%service/%username/index.html.spt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/www/on/%service/%username/index.html.spt b/www/on/%service/%username/index.html.spt index c16fbeade2..f3d7727c5d 100644 --- a/www/on/%service/%username/index.html.spt +++ b/www/on/%service/%username/index.html.spt @@ -56,10 +56,10 @@ title = account.service_name
{% if account.is_locked %} -

{{ service.display_name }} has opted out of Gittip.

+

{{ escape(service.display_name) }} has opted out of Gittip.

If you are {{ service.display_name }} + href="{{ user_info.get('html_url', '') }}">{{ escape(service.display_name) }} on {{ account.service_name }}, you can unlock your account to allow people to pledge tips to you on Gittip.

@@ -68,14 +68,14 @@ title = account.service_name {% else %} -

{{ service.display_name }} has not joined Gittip.

+

{{ escape(service.display_name) }} has not joined Gittip.

Is this you? {% if user.ANON %} - Click + Click here to opt in to Gittip. We never collect money for you until you do. {% else %} @@ -94,11 +94,11 @@ title = account.service_name

Don't like what you see?

-

If you are {{ service.display_name }} you can explicitly opt out of Gittip by - locking this account. We don't allow new pledges to locked +

If you are {{ escape(service.display_name) }} you can explicitly opt out + of Gittip by locking this account. We don't allow new pledges to locked accounts.

- {% end %} From 237d40cfdfcd7e59cedee5f56db1479d020fd166 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 13:58:17 -0400 Subject: [PATCH 011/101] Here's a failing test for an Aspen upgrade issue --- tests/test_elsewhere_bitbucket.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_elsewhere_bitbucket.py b/tests/test_elsewhere_bitbucket.py index eba1ce8d1a..0331521b7d 100644 --- a/tests/test_elsewhere_bitbucket.py +++ b/tests/test_elsewhere_bitbucket.py @@ -1,5 +1,6 @@ from __future__ import print_function, unicode_literals +from aspen.http.request import UnicodeWithParams from gittip.elsewhere import bitbucket from gittip.testing import Harness @@ -11,3 +12,9 @@ def test_get_user_info_gets_user_info(self): expected = {"username": "alice"} actual = bitbucket.get_user_info('alice') assert actual == expected + + def test_get_user_info_gets_user_info_from_UnicodeWithParams(self): + bitbucket.BitbucketAccount("1", {'username': 'alice'}).opt_in('alice') + expected = {"username": "alice"} + actual = bitbucket.get_user_info(UnicodeWithParams('alice', {})) + assert actual == expected From 5190ee7d5a73bb3d43534640b891957228216186 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 13:59:39 -0400 Subject: [PATCH 012/101] Fix an Aspen upgrade issue (UnicodeWithParams) --- gittip/elsewhere/bitbucket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index 5d07231ae3..c951c7f03a 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -3,6 +3,7 @@ import gittip import requests from aspen import json, log, Response +from aspen.http.request import UnicodeWithParams from aspen.utils import typecheck from gittip.elsewhere import AccountElsewhere, _resolve @@ -45,7 +46,7 @@ def get_user_info(username): :returns: A dictionary containing bitbucket specific information for the user. """ - typecheck(username, unicode) + typecheck(username, (unicode, UnicodeWithParams)) rec = gittip.db.one( "SELECT user_info FROM elsewhere " "WHERE platform='bitbucket' " "AND user_info->'username' = %s" From 6760eae99da211385651ebd9cb1094795f6f7e0e Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 16:14:09 -0400 Subject: [PATCH 013/101] Standardize nomenclature on "platform" We were using "Service" but in the `elsewhere` table we use `platform`. --- gittip/elsewhere/__init__.py | 14 +++++----- .../%username/index.html.spt | 26 +++++++++---------- www/on/{%service => %platform}/associate | 0 www/on/{%service => %platform}/redirect | 0 4 files changed, 20 insertions(+), 20 deletions(-) rename www/on/{%service => %platform}/%username/index.html.spt (69%) rename www/on/{%service => %platform}/associate (100%) rename www/on/{%service => %platform}/redirect (100%) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 8979f69e69..9c07785e9a 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -36,7 +36,7 @@ def _resolve(platform, username_key, username): ) return participant -class ServiceElsewhere(object): +class Platform(object): def __init__(self, website=None, username=None): self.username = username @@ -70,16 +70,16 @@ def external_profile_url(self): @property def get_user_info(self, user_id=None, participant=None): ''' - Returns a dict containing the user's details on the external service. + Returns a dict containing the user's details on the other platform. The following keys are required: `user_id` - The ID of the user on the external service + The ID of the user on the other platform `token` The long-term token used to access user data :param user_id: - The ID of the participant, on the external service + The ID of the participant, on the other platform :param participant: The participant ID @@ -94,14 +94,14 @@ def get_user_info(self, user_id=None, participant=None): @property def display_name(self): ''' - For most services, the name displayed should be `self.username`. For - Twitter, this would be the user's screen name. Other services may have + For most platforms, the name displayed should be `self.username`. For + Twitter, this would be the user's screen name. Other platforms may have different user-facing strings - like Google, which uses email addresses. To make this a bit more complex, the Google's email addresses are mutable. This method should be overridden only if the immutable ID from the - service provider is not suitable to be displayed back to the user. + platform is not suitable to be displayed back to the user. ''' return self._display_name or self.username diff --git a/www/on/%service/%username/index.html.spt b/www/on/%platform/%username/index.html.spt similarity index 69% rename from www/on/%service/%username/index.html.spt rename to www/on/%platform/%username/index.html.spt index f3d7727c5d..98a2c1f3bb 100644 --- a/www/on/%service/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -9,7 +9,7 @@ from gittip import CARDINALS, db from gittip.models.participant import Participant [-----------------------------------------------------------------------------] -service = website.elsewhere[path['service']](website, path['username']) +platform = website.elsewhere[path['platform']](website, path['username']) url = '' @@ -21,25 +21,25 @@ class account(object): username = 'External Username' #Chad's directive oauth_url = 'oauth.url' profile_url = 'profile.url' - service_name = 'External Service' + platform_name = 'Other Platform' -title = account.service_name +title = account.platform_name [-----------------------------------------------------------------------------] {% extends templates/base.html %} -{% block heading %}

{{ account.service_name }}

{% end %} +{% block heading %}

{{ account.platform_name }}

{% end %} {% block box %} @@ -56,11 +56,11 @@ title = account.service_name
{% if account.is_locked %} -

{{ escape(service.display_name) }} has opted out of Gittip.

+

{{ escape(platform.display_name) }} has opted out of Gittip.

If you are {{ escape(service.display_name) }} - on {{ account.service_name }}, you can unlock your account to allow people + href="{{ user_info.get('html_url', '') }}">{{ escape(platform.display_name) }} + on {{ account.platform_name }}, you can unlock your account to allow people to pledge tips to you on Gittip.

-

{{ escape(service.display_name) }} has not joined Gittip.

+

{{ escape(platform.display_name) }} has not joined Gittip.

Is this you? {% if user.ANON %} - Click + Click here to opt in to Gittip. We never collect money for you until you do. {% else %} @@ -94,11 +94,11 @@ title = account.service_name

Don't like what you see?

-

If you are {{ escape(service.display_name) }} you can explicitly opt out +

If you are {{ escape(platform.display_name) }} you can explicitly opt out of Gittip by locking this account. We don't allow new pledges to locked accounts.

- {% end %} diff --git a/www/on/%service/associate b/www/on/%platform/associate similarity index 100% rename from www/on/%service/associate rename to www/on/%platform/associate diff --git a/www/on/%service/redirect b/www/on/%platform/redirect similarity index 100% rename from www/on/%service/redirect rename to www/on/%platform/redirect From 86c1f5a5c3efcfb71d7b8d707bd896f8aef9e9e2 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 16:25:47 -0400 Subject: [PATCH 014/101] Add a little whitespace --- gittip/elsewhere/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 9c07785e9a..25277a5c5f 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -13,9 +13,11 @@ ACTIONS = [u'opt-in', u'connect', u'lock', u'unlock'] + class AuthorizationFailure(Exception): pass + def _resolve(platform, username_key, username): """Given three unicodes, return a username. """ @@ -36,7 +38,10 @@ def _resolve(platform, username_key, username): ) return participant + class Platform(object): + """This is a base class for third-party platforms we support connecting to. + """ def __init__(self, website=None, username=None): self.username = username From 808a7ccb88c78b7001dae99c52d36f6552523af1 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 16:43:16 -0400 Subject: [PATCH 015/101] Add .spt extension to a couple files This branch started under an old version of Aspen that didn't require the .spt extension. --- www/on/%platform/{associate => associate.spt} | 0 www/on/%platform/{redirect => redirect.spt} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename www/on/%platform/{associate => associate.spt} (100%) rename www/on/%platform/{redirect => redirect.spt} (100%) diff --git a/www/on/%platform/associate b/www/on/%platform/associate.spt similarity index 100% rename from www/on/%platform/associate rename to www/on/%platform/associate.spt diff --git a/www/on/%platform/redirect b/www/on/%platform/redirect.spt similarity index 100% rename from www/on/%platform/redirect rename to www/on/%platform/redirect.spt From ca9c7f4cad9fe6ea5c2f76746b83959552581f0d Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 19:02:38 -0400 Subject: [PATCH 016/101] Rough-in of an elsewhere refactor refactor; #1369 This adds a PlatformRegistry to website, which provides access to instances of Platform subclasses, which have a load method that takes a username on the given platform and returns an AccountElsewhere orm.Model. This is all barely working with one test. Surely other tests are broken now, I haven't even checked. Gonna start building back up and out. --- configure-aspen.py | 2 +- gittip/elsewhere/__init__.py | 254 +++++------------- gittip/elsewhere/twitter.py | 70 ++--- gittip/models/account_elsewhere.py | 41 +++ gittip/wireup.py | 10 +- tests/test_elsewhere_twitter.py | 19 +- www/on/%platform/%username/index.html.spt | 24 +- www/on/twitter/%screen_name/index.html.spt | 116 -------- .../twitter/%screen_name/lock-fail.html.spt | 18 -- www/on/twitter/associate.spt | 126 --------- www/on/twitter/redirect.spt | 39 --- 11 files changed, 177 insertions(+), 542 deletions(-) create mode 100644 gittip/models/account_elsewhere.py delete mode 100644 www/on/twitter/%screen_name/index.html.spt delete mode 100644 www/on/twitter/%screen_name/lock-fail.html.spt delete mode 100644 www/on/twitter/associate.spt delete mode 100644 www/on/twitter/redirect.spt diff --git a/configure-aspen.py b/configure-aspen.py index 72fb457023..28f9e87bfc 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -26,7 +26,7 @@ gittip.wireup.mixpanel(website) gittip.wireup.nanswers() gittip.wireup.nmembers(website) -gittip.wireup.elsewhere_providers(website) +gittip.wireup.elsewhere(website) gittip.wireup.envvars(website) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 25277a5c5f..f46a6f1b23 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -3,192 +3,80 @@ from __future__ import print_function, unicode_literals from aspen.utils import typecheck +from aspen.http.request import UnicodeWithParams +from gittip.models.participant import reserve_a_random_username from psycopg2 import IntegrityError -import gittip -from gittip.security.user import User -from gittip.models.participant import Participant, reserve_a_random_username -from gittip.models.participant import ProblemChangingUsername +ACTIONS = ['opt-in', 'connect', 'lock', 'unlock'] -ACTIONS = [u'opt-in', u'connect', u'lock', u'unlock'] - -class AuthorizationFailure(Exception): +class UnknownAccountElsewhere(Exception): pass -def _resolve(platform, username_key, username): - """Given three unicodes, return a username. - """ - typecheck(platform, unicode, username_key, unicode, username, unicode) - participant = gittip.db.one(""" - - SELECT participant - FROM elsewhere - WHERE platform=%s - AND user_info->%s = %s - - """, (platform, username_key, username,)) - # XXX Do we want a uniqueness constraint on $username_key? Can we do that? - - if participant is None: - raise Exception( "User %s on %s isn't known to us." - % (username, platform) - ) - return participant +class MissingAttributes(Exception): + def __str__(self): + return "The Platform subclass {} is missing one or more attributes: {}."\ + .format(self.args[0], ','.join(self.args[1])) -class Platform(object): - """This is a base class for third-party platforms we support connecting to. +class PlatformRegistry(object): + """Registry of platforms we support connecting to your Gittip account. """ - def __init__(self, website=None, username=None): - self.username = username - self.website = website - if username: - self._get_user_info() - - @property - def external_auth_url(self): - ''' - Returns the URL to which the user should be directed to begin the OAuth - handshake. - - If conditions must be met prior to the user being redirected, they - should be set up here. Example: Twitter requires a unique request token - for each handshake. The initial call to Twitter to get that token - should be implmented here. - - ''' - raise NotImplementedError() - - @property - def external_profile_url(self): - ''' - Returns a user's profile on the external platform, if such a beast - exists. - - ''' - raise NotImplementedError() - - @property - def get_user_info(self, user_id=None, participant=None): - ''' - Returns a dict containing the user's details on the other platform. - The following keys are required: - - `user_id` - The ID of the user on the other platform - `token` - The long-term token used to access user data - - :param user_id: - The ID of the participant, on the other platform - :param participant: - The participant ID - - Note that overriding methods should handle the case where neither params - are provided by raising an appropriate excpetion. - - ''' - raise NotImplementedError() - - _display_name = None + def __init__(self, db): + self.db = db - @property - def display_name(self): - ''' - For most platforms, the name displayed should be `self.username`. For - Twitter, this would be the user's screen name. Other platforms may have - different user-facing strings - like Google, which uses email addresses. - To make this a bit more complex, the Google's email addresses are - mutable. + def register(self, Platform): + self.__dict__[Platform.name] = Platform(self.db) - This method should be overridden only if the immutable ID from the - platform is not suitable to be displayed back to the user. - ''' - return self._display_name or self.username - @display_name.setter - def display_name(self, val): - self._display_name = val +class Platform(object): - def get_oauth_init_url(self): - raise NotImplementedError() + def __init__(self, db): + self.db = db + # Make sure the subclass was implemented properly. + # ================================================ -class AccountElsewhere(object): + missing_attrs = [] + for attr in ('name', 'username_key', 'user_id_key', 'hit_api'): + if not hasattr(self, attr): + missing_attrs.append(attr) + if missing_attrs: + raise MissingAttributes(self.__class__.__name__, missing_attrs) - platform = None # set in subclass - def __init__(self, user_id, user_info=None): - """Takes a user_id and user_info, and updates the database. + def load(self, username): + """Given a unicode, return an AccountElsewhere object. """ - typecheck(user_id, (int, unicode), user_info, (None, dict)) - self.user_id = unicode(user_id) - - if user_info is not None: - a,b,c,d = self.upsert(user_info) + typecheck(username, UnicodeWithParams) + try: + out = self.load_from_db(username) + except UnknownAccountElsewhere: + out = self.load_from_api(username) + return out - self.participant = a - self.is_claimed = b - self.is_locked = c - self.balance = d + def load_from_db(self, username): + return self.db.one( "SELECT elsewhere.*::elsewhere " + "FROM elsewhere " + "WHERE platform=%s " + "AND user_info->%s = %s" + , (self.name, self.username_key, username) + , default=UnknownAccountElsewhere + ) - def get_participant(self): - return Participant.query.get(username=self.participant) + def load_from_api(self, username): - def set_is_locked(self, is_locked): - gittip.db.run(""" + # Hit the platform's API to get user info. + # ======================================== - UPDATE elsewhere - SET is_locked=%s - WHERE platform=%s AND user_id=%s - - """, (is_locked, self.platform, self.user_id)) - - - def opt_in(self, desired_username): - """Given a desired username, return a User object. - """ - self.set_is_locked(False) - user = User.from_username(self.participant) - user.sign_in() - assert not user.ANON, self.participant # sanity check - if self.is_claimed: - newly_claimed = False - else: - newly_claimed = True - user.participant.set_as_claimed() - try: - user.participant.change_username(desired_username) - except ProblemChangingUsername: - pass - return user, newly_claimed - - - def upsert(self, user_info): - """Given a dict, return a tuple. - - User_id is an immutable unique identifier for the given user on the - given platform. Username is the user's login/username on the given - platform. It is only used here for logging. Specifically, we don't - reserve their username for them on Gittip if they're new here. We give - them a random username here, and they'll have a chance to change it - if/when they opt in. User_id and username may or may not be the same. - User_info is a dictionary of profile info per the named platform. All - platform dicts must have an id key that corresponds to the primary key - in the underlying table in our own db. - - The return value is a tuple: (username [unicode], is_claimed [boolean], - is_locked [boolean], balance [Decimal]). - - """ - typecheck(user_info, dict) + user_info = self.hit_api(username) + user_id = user_info[self.user_id_key] # If this is KeyError, then what? # Insert the account if needed. @@ -197,14 +85,17 @@ def upsert(self, user_info): # participant we reserved for them is rolled back as well. try: - with gittip.db.get_cursor() as cursor: - _username = reserve_a_random_username(cursor) + with self.db.get_cursor() as cursor: + random_username = reserve_a_random_username(cursor) cursor.execute( "INSERT INTO elsewhere " "(platform, user_id, participant) " "VALUES (%s, %s, %s)" - , (self.platform, self.user_id, _username) + , (self.name, user_id, random_username) ) except IntegrityError: + + # We have a db-level uniqueness constraint on (platform, user_id) + pass @@ -223,35 +114,38 @@ def upsert(self, user_info): user_info[k] = unicode(v) - username = gittip.db.one(""" + username = self.db.one(""" UPDATE elsewhere SET user_info=%s WHERE platform=%s AND user_id=%s - RETURNING participant + RETURNING user_info->%s AS username - """, (user_info, self.platform, self.user_id)) + """, (user_info, self.name, user_id, self.username_key)) - # Get a little more info to return. - # ================================= + # Now delegate to load_from_db. + # ============================= - rec = gittip.db.one(""" + return self.load_from_db(username) - SELECT claimed_time, balance, is_locked - FROM participants - JOIN elsewhere - ON participants.username=participant - WHERE platform=%s - AND participants.username=%s - """, (self.platform, username)) + def resolve(self, username): + """Given a username elsewhere, return a username here. + """ + typecheck(username, unicode) + participant = self.db.one(""" - assert rec is not None # sanity check + SELECT participant + FROM elsewhere + WHERE platform=%s + AND user_info->%s = %s + """, (self.name, self.username_key, username,)) + # XXX Do we want a uniqueness constraint on $username_key? Can we do that? - return ( username - , rec.claimed_time is not None - , rec.is_locked - , rec.balance - ) + if participant is None: + raise Exception( "User %s on %s isn't known to us." + % (username, self.platform) + ) + return participant diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index 7745bb939a..70bf5599ac 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -1,52 +1,25 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import datetime -import gittip import requests -from aspen import json, log, Response -from aspen.http.request import UnicodeWithParams -from aspen.utils import to_age, utc, typecheck -from gittip.elsewhere import AccountElsewhere, _resolve from os import environ -from requests_oauthlib import OAuth1 - - -class TwitterAccount(AccountElsewhere): - platform = u'twitter' - - def get_url(self): - return "https://twitter.com/" + self.user_info['screen_name'] - - -def resolve(screen_name): - return _resolve(u'twitter', u'screen_name', screen_name) - -def oauth_url(website, action, then=""): - """Return a URL to start oauth dancing with Twitter. - - For GitHub we can pass action and then through a querystring. For Twitter - we can't, so we send people through a local URL first where we stash this - info in an in-memory cache (eep! needs refactoring to scale). +from aspen import json, log, Response +from aspen.utils import to_age, utc +from gittip.elsewhere import Platform +from requests_oauthlib import OAuth1 - Not sure why website is here. Vestige from GitHub forebear? - """ - then = then.encode('base64').strip() - return "/on/twitter/redirect?action=%s&then=%s" % (action, then) +class Twitter(Platform): + name = 'twitter' + username_key = 'screen_name' + user_id_key= 'id' -def get_user_info(screen_name): - """Given a unicode, return a dict. - """ - typecheck(screen_name, (unicode, UnicodeWithParams)) - rec = gittip.db.one( "SELECT user_info FROM elsewhere " - "WHERE platform='twitter' " - "AND user_info->'screen_name' = %s" - , (screen_name,) - ) - if rec is not None: - user_info = rec - else: + def hit_api(self, screen_name): + """ + """ # Updated using Twython as a point of reference: # https://github.com/ryanmcgrath/twython/blob/master/twython/twython.py#L76 oauth = OAuth1( @@ -61,6 +34,7 @@ def get_user_info(screen_name): url = "https://api.twitter.com/1.1/users/show.json?screen_name=%s" user_info = requests.get(url % screen_name, auth=oauth) + # Keep an eye on our Twitter usage. # ================================= @@ -90,4 +64,18 @@ def get_user_info(screen_name): log("Twitter lookup failed with %d." % user_info.status_code) raise Response(404) - return user_info + return user_info + + + def oauth_url(website, action, then=""): + """Return a URL to start oauth dancing with Twitter. + + For GitHub we can pass action and then through a querystring. For Twitter + we can't, so we send people through a local URL first where we stash this + info in an in-memory cache (eep! needs refactoring to scale). + + Not sure why website is here. Vestige from GitHub forebear? + + """ + then = then.encode('base64').strip() + return "/on/twitter/redirect?action=%s&then=%s" % (action, then) diff --git a/gittip/models/account_elsewhere.py b/gittip/models/account_elsewhere.py new file mode 100644 index 0000000000..b5b57d1666 --- /dev/null +++ b/gittip/models/account_elsewhere.py @@ -0,0 +1,41 @@ +from gittip.models.participant import ProblemChangingUsername +from gittip.security.user import User +from postgres.orm import Model + + +class AccountElsewhere(Model): + + typname = "elsewhere" + + + def get_html_url(self): + pass + + + def set_is_locked(self, is_locked): + self.db.run(""" + + UPDATE elsewhere + SET is_locked=%s + WHERE platform=%s AND user_id=%s + + """, (is_locked, self.platform, self.user_id)) + + + def opt_in(self, desired_username): + """Given a desired username, return a User object. + """ + self.set_is_locked(False) + user = User.from_username(self.participant) + user.sign_in() + assert not user.ANON, self.participant # sanity check + if self.is_claimed: + newly_claimed = False + else: + newly_claimed = True + user.participant.set_as_claimed() + try: + user.participant.change_username(desired_username) + except ProblemChangingUsername: + pass + return user, newly_claimed diff --git a/gittip/wireup.py b/gittip/wireup.py index 0ae32f933b..8ccec21fa4 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -10,7 +10,10 @@ import psycopg2 import stripe import gittip.utils.mixpanel +from gittip.elsewhere import PlatformRegistry +from gittip.elsewhere.twitter import Twitter from gittip.models.community import Community +from gittip.models.account_elsewhere import AccountElsewhere from gittip.models.participant import Participant from postgres import Postgres @@ -31,6 +34,7 @@ def db(): psycopg2.extras.register_hstore(cursor, globally=True, unicode=True) db.register_model(Community) + db.register_model(AccountElsewhere) db.register_model(Participant) return db @@ -78,8 +82,10 @@ def nmembers(website): community.NMEMBERS_THRESHOLD = int(os.environ['NMEMBERS_THRESHOLD']) website.NMEMBERS_THRESHOLD = community.NMEMBERS_THRESHOLD -def elsewhere_providers(website): - website.elsewhere = {} +def elsewhere(website): + website.elsewhere = PlatformRegistry(website.db) + website.elsewhere.register(Twitter) + def envvars(website): diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index 8921e17881..335d55dd0e 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -1,11 +1,20 @@ from __future__ import print_function, unicode_literals +import mock +from aspen.http.request import UnicodeWithParams +from gittip import wireup from gittip.elsewhere import twitter from gittip.testing import Harness + class TestElsewhereTwitter(Harness): + + def setUp(self): + wireup.elsewhere(self) + + def test_twitter_resolve_resolves(self): alice_on_twitter = twitter.TwitterAccount( "1" , {'screen_name': 'alice'} @@ -13,7 +22,7 @@ def test_twitter_resolve_resolves(self): alice_on_twitter.opt_in('alice') expected = 'alice' - actual = twitter.resolve(u'alice') + actual = twitter.resolve('alice') assert actual == expected @@ -22,3 +31,11 @@ def test_get_user_info_gets_user_info(self): expected = {"screen_name": "alice"} actual = twitter.get_user_info('alice') assert actual == expected + + + @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') + def test_can_load_account_elsewhere_from_twitter(self, hit_api): + hit_api.return_value = {"id": "123", "screen_name": "alice"} + + alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) + assert alice_on_twitter.user_id == "123" diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index 98a2c1f3bb..e3aa5d71b9 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -9,22 +9,10 @@ from gittip import CARDINALS, db from gittip.models.participant import Participant [-----------------------------------------------------------------------------] -platform = website.elsewhere[path['platform']](website, path['username']) - -url = '' - -nbackers = 0 - - -class account(object): - is_locked = False - username = 'External Username' #Chad's directive - oauth_url = 'oauth.url' - profile_url = 'profile.url' - platform_name = 'Other Platform' - -title = account.platform_name - +platform = website.elsewhere[path['platform']] +username = path['screen_name'] +account = platform.load_account(username) +title = username [-----------------------------------------------------------------------------] {% extends templates/base.html %} @@ -39,7 +27,7 @@ title = account.platform_name
@@ -59,7 +47,7 @@ title = account.platform_name

{{ escape(platform.display_name) }} has opted out of Gittip.

If you are {{ escape(platform.display_name) }} + href="{{ account.get_html_url() }}">{{ escape(platform.display_name) }} on {{ account.platform_name }}, you can unlock your account to allow people to pledge tips to you on Gittip.

diff --git a/www/on/twitter/%screen_name/index.html.spt b/www/on/twitter/%screen_name/index.html.spt deleted file mode 100644 index 062c51e332..0000000000 --- a/www/on/twitter/%screen_name/index.html.spt +++ /dev/null @@ -1,116 +0,0 @@ -"""Twitter user page on Gittip. -""" -import datetime -import decimal - -from aspen import json, Response, log -from aspen.utils import to_age, utc -from gittip import CARDINALS, db -from gittip.elsewhere import twitter -from gittip.models.participant import Participant -[-----------------------------------------------------------------------------] - -# Try to load from Twitter. -# ========================= - -if path['screen_name'].startswith('@'): - request.redirect('../'+path['screen_name'][1:]+'/') -user_info = twitter.get_user_info(path['screen_name']) - - -# Try to load from Gittip. -# ======================== - -username = user_info['screen_name'] -name = user_info.get('name') -if not name: - name = username -url = user_info['html_url'] = "https://twitter.com/%s" % username - -account = twitter.TwitterAccount(user_info['id'], user_info) -participant = Participant.from_username(account.participant) -if account.is_claimed: - request.redirect('/%s/' % participant.username) -locked = account.is_locked -lock_action = "unlock" if account.is_locked else "lock" - -nbackers = participant.get_number_of_backers() -title = username - -[-----------------------------------------------------------------------------] -{% extends templates/base.html %} - -{% block heading %}

Twitter

{% end %} - -{% block box %} - -
- + -

{{ escape(service.display_name) }} has

+

{{ escape(platform.display_name) }} has

{{ nbackers }}
{{ 'person' if nbackers == 1 else 'people' }} ready to give
-

{{ escape(platform.display_name) }} has

+

{{ escape(account.display_name) }} has

{{ nbackers }}
{{ 'person' if nbackers == 1 else 'people' }} ready to give
- - - - - -
- - -

{{ escape(username) }} has

-
{{ nbackers }}
-
{{ 'person' if nbackers == 1 else 'people' }} ready to give
-
- -{% include "templates/participant.tip.html" %} - -{% end %} - -{% block page %} - -
- {% if account.is_locked %} - -

{{ escape(username) }} has opted out of Gittip.

- -

If you are {{ escape(username) }} - on Twitter, you can unlock your account to allow people to pledge tips to - you on Gittip.

- - - - {% else %} - - -

{{ escape(name) }} has not joined Gittip.

- -

Is this you? - {% if user.ANON %} - Click - here to opt in to Gittip. We never collect money for you until - you do. - {% else %} - Sign out and sign back in - to claim this account - {% end %} -

- - {% if user.ANON %} -

What is Gittip?

- -

Gittip is a way to thank and support your favorite artists, musicians, - writers, programmers, etc. by setting up a small weekly cash gift to them. - Read more ...

- - -

Don't like what you see?

- -

If you are {{ escape(username) }} you can explicitly opt out of Gittip - by locking this account. We don't allow new pledges to locked accounts.

- - - {% end %} - - {% end %} -
-{% end %} diff --git a/www/on/twitter/%screen_name/lock-fail.html.spt b/www/on/twitter/%screen_name/lock-fail.html.spt deleted file mode 100644 index 416d34605b..0000000000 --- a/www/on/twitter/%screen_name/lock-fail.html.spt +++ /dev/null @@ -1,18 +0,0 @@ -username = path['screen_name'] -[---] -{% extends templates/base.html %} -{% block heading %}

Failure

{% end %} -{% block box %} - -
- -

Are you really {{ username }}?

- -

Your attempt to lock or unlock this account failed because you're - logged into Twitter as someone else. Please sign out of Twitter and try again.

- -
- -{% end %} diff --git a/www/on/twitter/associate.spt b/www/on/twitter/associate.spt deleted file mode 100644 index 4075a8ba95..0000000000 --- a/www/on/twitter/associate.spt +++ /dev/null @@ -1,126 +0,0 @@ -"""Associate a Twitter account with a Gittip account. - -First we do the OAuth dance with Twitter. Once we've authenticated the user -against Twitter, we record them in our elsewhere table. This table contains -information for Twitter users whether or not they are explicit participants in -the Gittip community. - -""" -from urlparse import parse_qs - -import requests -from requests_oauthlib import OAuth1 -from aspen import json, log, Response -from aspen import resources -from gittip.elsewhere import ACTIONS, twitter -from gittip.models._mixin_elsewhere import NeedConfirmation -from gittip.utils import mixpanel - -[-----------------------------] - -if 'denied' in qs: - request.redirect('/') - - -token = qs['oauth_token'] -try: - secret, action, then = website.oauth_cache.pop(token) - then = then.decode('base64') -except KeyError: - request.redirect("/about/me.html") - -oauth = OAuth1( website.twitter_consumer_key - , website.twitter_consumer_secret - , token - , secret - ) -response = requests.post( "https://api.twitter.com/oauth/access_token" - , data={"oauth_verifier": qs['oauth_verifier']} - , auth=oauth - ) -assert response.status_code == 200, response.status_code - -reply = parse_qs(response.text) -token = reply['oauth_token'][0] -secret = reply['oauth_token_secret'][0] -user_id = reply['user_id'][0] - -oauth = OAuth1( website.twitter_consumer_key - , website.twitter_consumer_secret - , token - , secret - ) - -response = requests.get( - "https://api.twitter.com/1.1/users/show.json?user_id=%s" % user_id, - auth=oauth -) -user_info = json.loads(response.text) -assert response.status_code == 200, response.status_code - - -# Load Twitter user info. - -if action not in ACTIONS: - raise Response(400) - -# Make sure we have a Twitter screen_name. -screen_name = user_info.get('screen_name') -if screen_name is None: - log(u"We got a user_info from Twitter with no screen_name [%s, %s]" - % (action, then)) - raise Response(400) -user_info['html_url'] = "https://twitter.com/" + screen_name - -# Do something. -log(u"%s wants to %s" % (screen_name, action)) - -account = twitter.TwitterAccount(user_info['id'], user_info) - -if action == 'opt-in': # opt in - # set 'user' to give them a session :/ - user, newly_claimed = account.opt_in(screen_name) - del account - if newly_claimed: - mixpanel.alias_and_track(cookie, unicode(user.participant.id)) -elif action == 'connect': # connect - if user.ANON: - raise Response(404) - try: - user.participant.take_over(account) - except NeedConfirmation, obstacles: - - # XXX Eep! Internal redirect! Really?! - request.internally_redirected_from = request.fs - request.fs = website.www_root + '/on/confirm.html.spt' - request.resource = resources.get(request) - - raise request.resource.respond(request) - else: - del account -else: # lock or unlock - if then != screen_name: - - # The user could spoof `then' to match their screen_name, but the most - # they can do is lock/unlock their own Twitter account in a convoluted - # way. - - then = u'/on/twitter/%s/lock-fail.html' % then - - else: - - # Associate the Twitter screen_name with a randomly-named, unclaimed - # Gittip participant. - - assert account.participant != screen_name, screen_name # sanity check - account.set_is_locked(action == 'lock') - del account - -if then == u'': - then = u'/%s/' % user.participant.username -if not then.startswith(u'/'): - # Interpret it as a Twitter screen_name. - then = u'/on/twitter/%s/' % then -request.redirect(then) - -[-----------------------------] text/plain diff --git a/www/on/twitter/redirect.spt b/www/on/twitter/redirect.spt deleted file mode 100644 index d1f7832ab6..0000000000 --- a/www/on/twitter/redirect.spt +++ /dev/null @@ -1,39 +0,0 @@ -"""Part of Twitter oauth. - -From here we redirect users to Twitter after storing needed info in an -in-memory cache. We get them again at www/on/twitter/associate. - -""" -from urlparse import parse_qs - -import requests -from requests_oauthlib import OAuth1 - -website.oauth_cache = {} # XXX What happens to someone who was half-authed - # when we bounced the server? - -[-----------------------------] - -oauth_hook = OAuth1( website.twitter_consumer_key - , website.twitter_consumer_secret - ) - -response = requests.post( "https://api.twitter.com/oauth/request_token" - , auth=oauth_hook - ) - -assert response.status_code == 200, response.status_code # safety check - -reply = parse_qs(response.text) - -token = reply['oauth_token'][0] -secret = reply['oauth_token_secret'][0] -assert reply['oauth_callback_confirmed'][0] == "true" # sanity check - -action = qs.get('action', 'opt-in') -then = qs.get('then', '') -website.oauth_cache[token] = (secret, action, then) - -url = "https://api.twitter.com/oauth/authenticate?oauth_token=%s" -request.redirect(url % token) -[-----------------------------] text/plain From 1ec5522d494ae11349780b32e31430e39103b92e Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 20:17:27 -0400 Subject: [PATCH 017/101] Eager-load participant when querying elsewhere --- branch.sql | 44 ++++++++++++++++++++++++++++++ gittip/elsewhere/__init__.py | 29 ++++++++++++++------ gittip/models/account_elsewhere.py | 2 +- tests/test_elsewhere_twitter.py | 15 +++++----- 4 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 branch.sql diff --git a/branch.sql b/branch.sql new file mode 100644 index 0000000000..d4969e7453 --- /dev/null +++ b/branch.sql @@ -0,0 +1,44 @@ +------------------------------------------------------------------------------- +-- https://github.com/gittip/www.gittip.com/pull/1369 + + +-- The following lets us cast queries to elsewhere_with_participant to get the +-- participant data dereferenced and returned in a composite type along with +-- the elsewhere data. Then we can register orm.Models in the application for +-- both participant and elsewhere_with_participant, and when we cast queries +-- elsewhere.*::elsewhere_with_participant, we'll get a hydrated Participant +-- object at .participant. Woo-hoo! + + +BEGIN; + + CREATE TYPE elsewhere_with_participant AS + ( id integer + , platform text + , user_id text + , user_info hstore + , is_locked boolean + , participant participants + ); + + CREATE OR REPLACE FUNCTION load_participant_for_elsewhere (elsewhere) + RETURNS elsewhere_with_participant + AS $$ + + SELECT $1.id + , $1.platform + , $1.user_id + , $1.user_info + , $1.is_locked + , participants.*::participants + FROM participants + WHERE participants.username = $1.participant + ; + + $$ LANGUAGE SQL; + + + CREATE CAST (elsewhere AS elsewhere_with_participant) + WITH FUNCTION load_participant_for_elsewhere(elsewhere); + +END; diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index f46a6f1b23..a05e542d1b 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -50,7 +50,7 @@ def __init__(self, db): def load(self, username): - """Given a unicode, return an AccountElsewhere object. + """Given a username on the other platform, return an AccountElsewhere object. """ typecheck(username, UnicodeWithParams) try: @@ -61,16 +61,29 @@ def load(self, username): def load_from_db(self, username): - return self.db.one( "SELECT elsewhere.*::elsewhere " - "FROM elsewhere " - "WHERE platform=%s " - "AND user_info->%s = %s" - , (self.name, self.username_key, username) - , default=UnknownAccountElsewhere - ) + """Given a username on the other platform, return an AccountElsewhere object. + + If the account elsewhere is unknown to us, we raise UnknownAccountElsewhere. + + """ + return self.db.one(""" + + SELECT elsewhere.*::elsewhere_with_participant + FROM elsewhere + WHERE platform=%s + AND user_info->%s = %s + + """, (self.name, self.username_key, username), default=UnknownAccountElsewhere) def load_from_api(self, username): + """Given a username on the other platform, return an AccountElsewhere object. + + The first thing we do is hit the API of the other platform, then we use + that to upsert our own elsewhere table, before handing back off to + load_from_db. + + """ # Hit the platform's API to get user info. # ======================================== diff --git a/gittip/models/account_elsewhere.py b/gittip/models/account_elsewhere.py index b5b57d1666..80ddac0bf2 100644 --- a/gittip/models/account_elsewhere.py +++ b/gittip/models/account_elsewhere.py @@ -5,7 +5,7 @@ class AccountElsewhere(Model): - typname = "elsewhere" + typname = "elsewhere_with_participant" def get_html_url(self): diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index 335d55dd0e..e2ee6eee68 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -26,16 +26,17 @@ def test_twitter_resolve_resolves(self): assert actual == expected - def test_get_user_info_gets_user_info(self): - twitter.TwitterAccount("1", {'screen_name': 'alice'}).opt_in('alice') - expected = {"screen_name": "alice"} - actual = twitter.get_user_info('alice') - assert actual == expected - - @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') def test_can_load_account_elsewhere_from_twitter(self, hit_api): hit_api.return_value = {"id": "123", "screen_name": "alice"} alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) assert alice_on_twitter.user_id == "123" + + + @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') + def test_account_elsewhere_has_participant_object_on_it(self, hit_api): + hit_api.return_value = {"id": "123", "screen_name": "alice"} + alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) + import pdb; pdb.set_trace() + assert alice_on_twitter.participant.username == 'alice' From d1748ba47bc6c7a815e49ad82ebe5c8cf5cbe92f Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 20:20:51 -0400 Subject: [PATCH 018/101] Fix the test for eager-loading participant --- gittip/models/participant.py | 8 ++++++++ tests/test_elsewhere_twitter.py | 3 +-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gittip/models/participant.py b/gittip/models/participant.py index 7a4d5c3c5e..9164dcf1c5 100644 --- a/gittip/models/participant.py +++ b/gittip/models/participant.py @@ -139,6 +139,14 @@ def set_session_expires(self, expires): self.set_attributes(session_expires=session_expires) + # Claimed-ness + # ============ + + @property + def is_claimed(self): + return self.claimed_time is not None + + # Number # ====== diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index e2ee6eee68..526b3654d7 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -38,5 +38,4 @@ def test_can_load_account_elsewhere_from_twitter(self, hit_api): def test_account_elsewhere_has_participant_object_on_it(self, hit_api): hit_api.return_value = {"id": "123", "screen_name": "alice"} alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) - import pdb; pdb.set_trace() - assert alice_on_twitter.participant.username == 'alice' + assert not alice_on_twitter.participant.is_claimed From b6f63cf341fd0a95381dc750fb0f5bf040340d0f Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 20:21:48 -0400 Subject: [PATCH 019/101] Note lack of type inheritance in Postgres --- branch.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/branch.sql b/branch.sql index d4969e7453..9f8799034e 100644 --- a/branch.sql +++ b/branch.sql @@ -13,6 +13,7 @@ BEGIN; CREATE TYPE elsewhere_with_participant AS + -- If Postgres had type inheritance this would be even awesomer. ( id integer , platform text , user_id text From d57bf6a15d8f5cdea7f8c1bdc199f53728a7620f Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 4 Oct 2013 20:24:00 -0400 Subject: [PATCH 020/101] Tweak whitespace in branch.sql --- branch.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/branch.sql b/branch.sql index 9f8799034e..400ff95a6a 100644 --- a/branch.sql +++ b/branch.sql @@ -13,14 +13,14 @@ BEGIN; CREATE TYPE elsewhere_with_participant AS - -- If Postgres had type inheritance this would be even awesomer. ( id integer , platform text , user_id text , user_info hstore , is_locked boolean , participant participants - ); + ); -- If Postgres had type inheritance this would be even awesomer. + CREATE OR REPLACE FUNCTION load_participant_for_elsewhere (elsewhere) RETURNS elsewhere_with_participant From 323c9d2daa6f38105e67812ece2a7f749f75a747 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 15:16:26 -0400 Subject: [PATCH 021/101] Upgrade Postgres.py to 2.1.0 I want to use __new__ in the AccountElsewhere model to vary the actual class used based on the platform coming back from the query, and this new version let's us do that. --- requirements.txt | 2 +- vendor/postgres-2.0.0.tar.gz | Bin 13001 -> 0 bytes vendor/postgres-2.1.0.tar.gz | Bin 0 -> 13264 bytes 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 vendor/postgres-2.0.0.tar.gz create mode 100644 vendor/postgres-2.1.0.tar.gz diff --git a/requirements.txt b/requirements.txt index 0a5b251d70..402bcfbaf1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ ./vendor/aspen-tornado-0.2.tar.bz2 ./vendor/psycopg2-2.5.1.tar.gz -./vendor/postgres-2.0.0.tar.gz +./vendor/postgres-2.1.0.tar.gz ./vendor/simplejson-2.3.2.tar.gz diff --git a/vendor/postgres-2.0.0.tar.gz b/vendor/postgres-2.0.0.tar.gz deleted file mode 100644 index a912c7c668416b464abf031697431df1cdf8e275..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13001 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 Date: Sat, 5 Oct 2013 16:00:04 -0400 Subject: [PATCH 022/101] Upgrade to postgres 2.1.1 Another fix to make __new__ easier to use --- requirements.txt | 2 +- vendor/postgres-2.1.0.tar.gz | Bin 13264 -> 0 bytes vendor/postgres-2.1.1.tar.gz | Bin 0 -> 13451 bytes 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 vendor/postgres-2.1.0.tar.gz create mode 100644 vendor/postgres-2.1.1.tar.gz diff --git a/requirements.txt b/requirements.txt index 402bcfbaf1..fe010728d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ ./vendor/aspen-tornado-0.2.tar.bz2 ./vendor/psycopg2-2.5.1.tar.gz -./vendor/postgres-2.1.0.tar.gz +./vendor/postgres-2.1.1.tar.gz ./vendor/simplejson-2.3.2.tar.gz diff --git a/vendor/postgres-2.1.0.tar.gz b/vendor/postgres-2.1.0.tar.gz deleted file mode 100644 index 8937b74d9b3642f5f53a7b3af154b0b001a1f4f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13264 zcmbW8Lvt>S5`<&hPEKswwr$%^PHfw@ZQHhOJ153_zu$0|Q`L)Ebv@NRLl_MOg>I^- z3<5B@_ldMX%!8DWc=rGdF7o?^NdQU#_k?(OJKNp<4IV(ZuZZ^oo{kcDyB{#~ zywky|5j@e=i~`&~3mf)MWM(bqiEBl7-$Hqr-oKy6yZdk7@o{(fcJqKYUVxuK%y7^C zo5i#a7vSsdjx9$(aO}5`AMgYDMlkRp;Mw5eDRJ|kG+Ka%hX+$b`Jegp9xL`61P&0y z(Fb$Cv@vq%Fso!b@r)=@-OzOPZo3d`Ix&{{U!0gHq%BehGwLUrOL+55=adPld7fya=3>eL`MtUqPyJc2hggSTMwqZfw1>38c%$ECA1D#X$BaJD zJ|92!i#M-4ih~bKCRhvN6Avgp`P+rcZ##k^K#kjVBy5IFSTy%TylB!=lGST{3Dybo z^r%7IlTX^q-CpQRKYa79(gnrk>FzY+{BY(VkzmffQ&l?foaFskpnpFsfcL^H{8uO) z!})^n69j^g!ife`CNsMFfbOP#AU*W4Gy~!QZgKG->K$S0()A=WRb)Dob{jE7Fl$W=U6phoyE*#L2eL z?*8Jn0nlI8;6M3KpvV+WRO8`CZ>K3Y?!0#fO>+9XisRkDh1?5EAa@)zkpkowX5&we z4t;q;qmH@ad(B(NubT)-enfP7m# zQ$kWc)|mK2`R<$!bKK@WFH@8wGK_c}Jw{syESKp2xPBg?rR5PUYp*ctPbekDZJ*g} z<(O!m!AryhS=kh`fn;&iAVUMyH@fCMi0oeH_jlp-Z!142xsg%ySc+>y-ix~w9hw`K*}&W1#sohi8_X=h#7}QUzIv9 z;gVjua%g~=1+0v|iTPuV9q(!-KsfdZ&KQ0Q3~(HEevEx3e-VMBkepC9c+{#!aZZks zW`rq0U35S_(*&AC8OxG721^Ls~Uh#M%}wN$N5b^%tT)j0x`w z@Z6IO#ZG$6LgR-dq#lN4dCeD646;Sl17YC7Di_d!kk}Uabay=^*j}f+y$*We zKScsH6|5fl*ameWPm{a^t9i5!ale*%=2G00`LIi%yyy;a7rax&WT@kUa4RdS0z}~j zF9Q|{EP*K}!1>4TbdrBNJUNq%oeAXQ{KDD51!n>g0%o8nFf6VX-d`l^Y4t_xA%qoY z+INLUjsb5KxrpCDgunK=L?KUs7`f7X92WNrk72e12GasX1uf!Go>?bJf1(#*!Bhu= zz$4!?Pmb~Rz#qv3g^J!|dQ)8znn|q50$VLVnWe`DdIfiS@0w!l2Gh8{m6&tG{)+c+ ziV)WgQTE~hzaigXL3LD@G}e3Pa3rnR`=#6&`5HYwh&#GCp|(g=<;RSYRjgx6F$!U0 z@sitHF-}`A>|GIveBQ;_N2sS}9suitp2_bRdQT_y(Q_FO$!~26uo()U+S`qj?xd*5i*d=KO|o1_wK)22vjr zqc(guYrAYuuj)d4x%b)3GEK1x&K59W4m<&()IoH*;ldH~K1^3skF8nQ@Sz(%RuG~y zUathOK|Mt2%Gv04$pmT`v0V{VgifO+PUbShk4QGXAFM7EnMo=o{ouh1oe+X&WEPU3 z6#F-L288XR`HbSN`EX;UDW+x?qbQk87!;I126y<3Jp?pF{GE6&%`nU&bO8qT&=Qk} z+i8`4O0Hu`ZH0=CU3Z;$CgMWY$`X)&Qitp_W~ONZD;GNu>hL5P;$$T)K1Q-~ms9V3 zg!|i5o%L7tlGQxI!j!{VHfZc7r1+BKO|;lh;&wxLqIN;p6`F^i@UtP`>=}&2! zvn?m|H)aK&0Gq%27P?N5A4bA?R913?6h6Ro#^KBGCcHFl=i$yfliXubQ6U&@s{q@mQFFrvYr5UT!a40wpxYbr?KhT(Te{$q#>5|4 zKb(aN0rV<1|4w+j&+7W`fA~NUDAq5K3Hm$XXOMjdgpq8SH_h*V7v=QMJ`-^^C&)4V zC5fF0QO$QL@yAk62n?ujY$7c)y?i>a;%^_5x@r+l)i`f#zzXQ?OUEq2ls=ymM)sN> zvOG)~a0P^egc!yG(1{6lQ7ihxhSK4RVGRX{)Za;Woe0Ue192y<7LIl&S` zFu?=*o0QLc5s+VHe`B($w^8&2PD88FJ^pb6BhNS71EilmXJ?>LE(|(n#kPYlhmpv4 zDRCChd5$AZzK8w%0CxTPR8C%s&F9l-3OyD?VgHn1!d!uuM>j@ZobUk7mVfFk6KUY@ zb%WCq^y~3v2*&mbhZmu6;u>Y(TPgwKntsqec4J9$yNrH}*@5co^!hLpjA^L0JM+oE zoYC74`w0DUUGYYsQH3l16^Y|lC?Gs7hhqVxAFJ0td$<|bdPu#3u1Avvr_-!M3LA30 zWYZXy8f{|-4i^@#b!Y;IE){C(GOl$J*m<3K1uMgTaueHVA;gED01&@FM1h9(~ zMv(!2!C`#;oyT_D&f$J9>v#VgSYOEAgZsG4kK0B|7mcJDU})ENdbcZO%sm0KqHudK z{c>%SNm?LKr00ne*y~9J+YsyQ*Q<#K+ArUP>CYc`@?xoS&0eqNIT|Th(#S>50hjjx8TzAQ7r|D%&2bDV$y7*^w@MQb}xcoW8v|+ z+giL67iZT$9Bb;jKrTFx&g=q(m*NKa$Oa~Zl z0kC62!A`zOelwVbOx0t*gcnDfk;uqOjpdmwtjN2g*x2s2o_U#GhUpltYz1TX|I#cA-D3tI79h+(3X)!v8^B>%=$ zR<;2LcAsXD4bw)h zf^VwSq~(Zd8x-0K9S`0QOs?3(WJj}^&4G6<7W`9#GL}GII(BMzy%M72_F5qtkQCX+ zb3EIhLMvu{(ilV;CX>VE9UW;{Jc{Rmg*~U0*jbVQE}ArnhGc zj$}HNXlg$%wZ16^@TNPkzbgG6TP*qZLWgN+oCb12H%>vUz-luxFghE^g0gm1X?Zze z)@II05?Wf$DDq;KN7(7bmQnN=k26@=1Bd_hDSF02rjR>J*O22^c*~`yM+_jL4o_1@ zNs-Hv-_}RIK8P_pu|;h5Dq%2QgF`@}ol^?oPY36828*`o!wX2@%EAo97QrTi1t~S; zMR=?x3PLTQ`T$~V7;ccul34)T%)Q-V9+EC98G&}E!K*oF-TUG630pUNT@-Nd%pX;=)Eh073|RO zhZ4uMpgBS{d`WUQ1G8K*T0v-rGn*Ow^+lKA<=hAc{P`Wi=7~0C zM=J=Rt{2{mlMSSkXjC;eMmhC2rec!pWXq=t9U$(*h>5}Yk3V$bq(FL%sX?3#lJILI zs5VP4I5vnwg)FD``}-rihx6&~pPKIP_BnhJU+p*E4Cj}X>nxyua;OE1Tro>m>M+3K z-@3KSc=qq6tUz##YjYCZNpSi}cJk}HS+-uT8ttw3&Sm;bsCc|Fy&e8WG zTsUbDGSJP{TR7|ddA+wx9cNl1OK$0Vth?xHL%wg+l2dr38(Ej!(F`uTV;2UmeT=x# zgCJTmA=aA=e1@y?c=$KH<7FUr+XA6qJU|sG10;TSD-iM4n6jSOt#op7fm_4XX01k- zD}kX}f^`!ExphF{^17W$^4?N`Vj!Omi?5e_=Lu< zwSnW&QkKy718=@|J=S4iyMV?OGO2(>4i=P4QjZyN!7s6X`&a&~^;uA9mP9wY#@=5c ze>e-@Y;MYtq4^rBN9HNX7ArH#}~M*`IoIM>b9s$Q;%l#q&`+C`Zs;EHQo>Ps0~(m z&zZU4ijwK4;V+KR0?aqFpb7#|HHtHgDpg=!C^rMu8RWlJo4Du3y*se39?w_ZQllI` zjB2zLxkwRuc;Bse-n+$4HGlb#VZ#`-js(uKea@~!3!-NqteWpC* zDfh&Zc++5uaW(vZus5~@@N!ozD}J>zI-(w^HC0^$k@m&@2H%ni%Wg%BW5kIbwPb@| zn$_f1?m##jB9ux~<0g8zaWWu~h>luHG1Gr|F!nFjBfa64X4XoN$m8cSym+S>N-@fguR$n8FtYlRh?PbIO!{ldW*r zFq;0+NkNdfHiNBg3|-S2yM8@{m}bbBFj<&3Yef3g6u2JXf;!e}igA&=OoT*dpPU^J6g;36T?@jp$hlpzJSk;h2nnRcWB^02C>Gtsi_!?w z^2g!X(Y2O#7(=+7`A@KIByB*Dq=W_v#$1Ou(b$U4hm(_Z5}lL@j2qpVn2{K4t4Yx~$Ga#Q%>{{Qqtu}@yxf^Ph4h^%ese%UBcvT((804>7vA(6RFlR>@lU7= z@Xu)vC0xgNYq46qG9>4VATkM%(iEg1O98gRIcQ5@GW;ZHImZTD?7cE0UF!9y{QHUXDM*OZh@W-sW@GnJL0&?C6cfcp#$nkoc zOQDU-w1axoG@5^LWPg;F+N%~F59SjkCPh=I5=mjzhF#P|5x0bupgQzBHGLEgx~+Lp z*9T4IdDo{%DUK+DgV1D>TxM1|=%I?5#{#v3=`8yIB4p#^C%&dJ#`e7g^W7VyE@0?W zXu5Z%Jz|`eG;Mi+lXxzL%`-eqg>LDU-)S?prK$_sKo_IdB3T@)b#ElevNz>JI9Q!d zsamG^7|xWsNI8HQeWD_oMwcSh3KTovqOuDlpz=&Fvl*3}ICBHl0aoL^vKm-1Dr-Y= z;ERv^4Cs`6m7l#1Xu)vVZO1u1WEY$wvv&_qd0WTdth+ z#LAFZv^IhloqN4I^;TnQb&I6o>jG=|6_cA3Ce?(>@VN-C>n?)KBIA?9A~lOM0zrFd zG%@+Gz8Bd3us~eNwno7?edZ9xrm0X`YMnjGMWotvQcTY(kMgidVN}0@n%|sKKQe}C z0#y*!?g_p-fnoNh`o4kp(;RCS3z2MLu)s8}v7BZOnp?2qdV%eHWT zA8E9vT2kqQ{7wODG-bMZCGKebxqpYG!OI@t*zA0M62c!`z6y_TZ_w zny#M*_a2Z^$`(kJyNDjM(K=Z(`jS5wd%%P3)hMNBp2=8r9kysE|9ub@S(A~r*bqJE zhkOEWR_9YT_*vV=w_+t@6`F7&LZS3gh8O2ZR0?F{OdO2LeeA{(Cd8n>sJ^;{I*wxx z5uH418GWH3aK1Eqne2tj>G9naoH9L*dJ((W)NM1SfQzz)+KSuQHFllm9|dw)Z$;0I z+LTUWGMTb1#g@Oya%Sq*T4?W^o=Nvj-}1 ziT&pTM5n5aO>qOsbLh}Cp++`Ht5OOy1D!%h6=6H+jPd;JVVf>wVC6F7k>~4``>BqE zNh&Mx#_FpD|D(0tOhX`6ey=1%i@!~bKCn=9$E6E7U@}K zGF@xpY-N45S*|?#A`?BnXh#gj`+B1sf^urRy3*r+zL33~4;Pr^6w;^47c9}>sFvgR zGiQSm|H%yh+&cIdlkzHZo2Otz)*blQ^BJgM3dOEFwm9ka^mW5H2Y{q7$F6VRyn5C` z_^7a?TaSWxhz|eZrb&jHp)rI&K+2G(=PD94#2cTpp^V~%Qu$Xt*`P{-xB^a1Lsc*Z zfTqS4TUnW=OrT>i(13tBmU{BKhX-0TLPuX=d>>C|qK>*t^#EX`5wdT}m5{V_y3F6l{I!WIr_0lZ~b^pFMhU4X4uTk6mh{(?x07PXVm|#!biN zekko;D96TK@=l4UB2EzRoN}M(yUAB^l?o)CFxnkZuRCX2k(Nhv^yQB0m-VCTmz?Fyu#==0xq(L{pcRZL%N5P1tR$P!y2Dxn;|2)dzg}M+bKR}XA@F$CG=nRrWAj`6=Tehj}`@Ij@%u+>Iqi2CE^dC;DRVFjrgTc5-95fJg782>36Ld z&o?Msb&bH$x~k9FpTTpJBjCar>Q#%lPE}nlSQSDWrO>lP(P=Fze2L+HXF8pCWsUm9 zDzzJo5~j@7SxpTU17N!VADvJQdC11F1|Q^`A<8K5hn?}3hQ?OrcR_w9ySmt+ckL`4 zrYNMRsiNz$YKK|Hy^I8EcZ_!fwD0yPy9`H8o<|-Ur8w&_tc4qz(7JwMQYPJ z_X6oz3RKh`APcjC3c@v*0Vly72>H;ndR1iD8USOIFt7TOrYl(y@FgASt}cs}xKB1> z4e2rdG_i_GXW__=(G@zg8LL{@fXOwzfcNV)F>Ff9 zg0kQ)@us_y8mikRm*JcywA~szUll~mbtuMD@R7brwU9Z@#HpiWYVxqo^-TCT09fYOt7L=sBS1Qh;&0bWlHpi_P zg!HFJ>*kTkO9)T^t2T84GbxL zp$&eYJ_B$4TH}dUl7H!*MDzE36r{bBL%(YK_FBSiM=MjmygBz(4z_PB?8+)^f66tFniql$p*E{keZWMyk+@?e(0dM^mOoD&%)m*_0X&k4;1B zkMzI&2?2p5mL5Gk{v+j=zi&^a82K)U-&bGzZ~v9_yBu%*gnRyu7Wak#%X>p_z}LEG z--sH2m7u#_gw+K4765LPXe87?l9GyeD({T-Kdv?+8oBJ%nso0!Qy zwVJN(()cKH_g$!qCBNt!q`km?&bF$B&NgpVV}<8r!%VAVg>;%yXFM=?x;B~E@QfbT zr^r9L%Fx_odj=2K_N^BMu0&%rxbP`Muw`49#xqK34A(Cid+MMx>uF;v@}A+1C?dNS zJ-4aG#<2riv2zy%7}hm0ey*EUk9*0T18GsEE|kPE8jv`eH{!fO%|hy*!2a_&Q>q=$ z9Xh&3S4T8c+qk&I(0KWl$xtlkm;5;XvZh8Ha3-J}Y1PM1>B7>8ZGAg*DNw(&aG*Z; zo%#t{(5&QVvN_~F@}K3bAmNQH#y!I6^#4+ZYI{9}Z87uyNm*PeG{5yb)$NbbUlGi@ z$Yb`UM`STfJfLC&<7bPQLR|?v3*zkShI)ZM`4%x&J%Jf8nDI==N}Q zgw);@=;&Vs^o@y#UJb=f(cf7st&c-J;llBUTj zTyl^#_Ft==`T?f(cw|h%m_D0cX4wRvQ*S$*;me#0F2g>vLS$9%&uaR4n{TQHW0HxQ z@ThTj1m11)1hT&az|D4LI+YAH{UdIQz5j6^V_jt9e~jqp1GDcI~H8&^XXNFOhjMcP|=(IJJ+` z!?jdYRzk5kN~Rp}R_P~L6Fy|5of3*PwO>vBDZ=gD#MoYN3T|rYicYpLzqe8FSxRkP zy|&6VTQOLh%KlJk8qjI(%6S-0;|s$J%L?sMpPu5IU(@*qLp+pePDvA!IC5BLI>*Ic zDp1d#IahA~6WKSarWd;B9A@G}mQ!{1HRt;{Vb!=}w$x6;{f|6@bwx21qN$`#f zQ(=^50;}fIvRgOBv2^-R0BPK%&C;xGL^F9r>_aWK#vX1 z=QBGRX|Ze_6C%9L#{WHMy{O8jKVb;n4h9-UmT4;>6`^q8dA-92@c$fSf#e4z%^m>NQlgX-kB^I>6kxo~lxq_tOk@Kp# z3&})}bj1tY0hgxL*M9RiKCrUg=*z$XAh03O%b z1uyZ7&G!g+KiMJ}P_dA?2Nj4^Z;cU422A=gG3%u?t8g0^Cs0Z}QQNT*p#iLd(*iPf zlSs4P?*=s$tvreUaMAaQ)7B+vT=e9suMcl04x!jMVrJ5Wrc$3q*v8UK2-qpRWf0y5 z-RMeMjeQ9ln-X3P4G{~%>}QlrF{c-ctwN-sV@_l6$f4H;^b{*U-7Qr;N++_f#Pq{p z;W!&pg@+l#GO6UbsV_4>cWK>-nJG`Cne4H2^Ge(UMgGX>4)?K6d*jf>H zta!@^111fY&4!u#yn#X>LpHG`yS*h`kNLVw$Xx;mQ0hq%rx(@mhV&06B{{>u+O_kM zvo_^b=ElhvYP3tCNa1mie`EHlyUe|NM6w?O-{H=^Ial9&jnl*aX}IA zXlcdv!)`0xd&F(a)-T$YkI`3KTSv%VLT?tfmUHWqLh}l`+Ck6itgSLlsGt55<-X0!G#9ES9LdP{dw>I(HT>L!q$ux z!9=_W`A+^Yu5)yTcS%#I6Ft*PGcKAt;bdK)2I*5rOY>G$v4LMFDuC0utGX??Yg-%D@N0Edw=hlpJ4ss0hWRHJD{7z(}p>ZUB>Yg>`N$t&aIdh?QI zYbn{qh`OS@6kOXbTLHeSv-~Va;yOtXn3rAr@wu2Y^n$;5H6y8m~@HkJf zywB-pZvBDf^lVj)%$3xHD!w6$lkLHE1zaT*%B=LSxa=@-oEfqDFnA)g*NOYM6=SH_ zSir);lNtSvkbMc!F^_Dov?lB-&cDU%P!%C)3t6$0zeFeO|6ooSM~n+T?D@y7X@$TF z!kZ35KQB6I51cZVc8f+lViR;rRJQt>*d_yd^VScX$GT5b!{p>-_|L|rmfEf{tuc+1 zJRBkQG{@vFeu7l9#FU=WtG#)wVF<9}RU$34%-Hy@O(PFes7W62HiL>w4cW;*?V%MI zvZY|UdW5Lvl|SUG&C4w|m?t~6n_hrF=gAtXo!ToL+i72eHCcW8Hh0-SaU9#5pMgQF7wzjIRQ{f&S;*&CKQp_5Y29oX_tTg8&Y{ip3f*M}Vr>`2@BYNOw_Y*apI zL&wsdUvQBh!U;hjlR|``5TIR|bPx4Lg#trt}C`$)y`!EubXxRcMS=jAt($ zEBKM0YPxRY@5eW?v{VQFdATd{>|=Xo>sQx<4cn*ax_>CW9s$~xhdLWZ?&qXoDb}z< zL)P$@NsbrUYh~t)bnQG?ryJ$f1r+`2WM_HvqFO%2zg?aVH^DZ9+x=IB*S9Id-#q?h zg?ttlf&p(be3B&C)lhh;b&8|5O=bconc6Ab9&v|C7(7iG}p4QyNsc3K^IP{6_(^_RF}rGErYb&3m%@)BMW|G_<7s2r)%+ z%PN^&M@G0@thYj1Nq-5*5$7vyFDU}6UQ~u-8zilxWRPV6Bbf>13`19}U68_YYYTkl z^U{Y)6b4l`be@F4W+8x!^UXz+W}W5Y@l2=Tp=O=xK^2`^w^RJJbvK#@0XUwc zLCF0gJQ2|>4XSp<{j(FCM;iedtVO$YS)24&+oZl`HA!=(*7^kPiG^NBJRR_g+NL(Q zX%M-0@u1=mm56TylOvmZv_GS)6%^YqWKQy1Bh8S#hT9G`4RjkhqWyjj=l8HqV%*MUB%i zjMZV2!LF7@g%U*>Gaj9;Nl3xrr!Q|Ze_kY;Z%pK06&(8vQm!)8q zPIja@)dr(2Nwg6f*mU^OLSVpULLv~S9*)My@^hoHJy&}@DZAf)Ck`qmDqMXYg%fdu z*)W`0%X&>Jm;HY1Zrg?(JnLr+;f=GvS(cLWG!9w+%z+ zk>rZy7vL^1wAED6m^wQyV#`HU1!$J?a3}f*cIZju`!&}YFK8|%D23H>MNv5{p+r`i zP?F%wnR~`UoqMHY$*UsP$103_XUciF#>iZuDL&u0@v5g{tDHi^96wW+Dz`q3+tGnF z{6`{aEL30f(1VL$I-EyR@7k2p;lFLNUmJ9-U!Jv(mo~+-eJ9@;4D|KyjP-wab)o=X zj;<@Jfau@)qke=}gg8dP*TKUezEevq;PYo5@ZtZ{;r;sWv6%q_(B2--m{J#ePEe+nk zPl~?nG=WwUe2-6;{(Qc#SCwz1c#~MmQ2#LFP1&VInq1Hf_~V1{W|2lYbUyo00Q331 zK93LocINj_4PXF>gm|eB{{H^a0E2U2JuCia3z>OQKO38XXEQl2#riV!7>y{xx2%uI zc9a!sztaM3?D#a_o&z!)%QWd43)*bIxn!+;<9U9_0szCI|8Cnp^Y8FF*|z}iPIvn# zp#IU;t(fk&e#AdJ&+pGK;&Ohgl|I#^HFV&!guVx@-cUZ-F;9 zP6ro@WHx*C_IaF680WcFf2FZm6u)G_EZ;H2r*GC(S9LAoSQr>l zCuLPIpt;R&w_naqzul}{Eq*hyGO_#@HxpNo8-F{`9nKtQ!6&T)_l`+bsfrW5zFJ(* z4TZe*UYnHo{OM=&-xRVT_Uma?DQTGHsXsS&zd?nAuw@kU+<55&qR3%ET)c0&57?;m%G7%YcPXVw^`aw1sOoHxGE6%8)fXLgY1 zu~`WZLM3sG*HjXnC}zyyE~Lf=PsdPeDv2efRtg+*(za+P3$hlwi4b zgq9-WGF60Blz=yZMZUrzODUD0{64~~hrSrt1AM1YX1LE}jQfng$R@A<>X5~tQ%U_F zTpu3$Hg4Sbm4=>vnc^u*Ox>aQ2u5U*^okrcPP(EE;+kG)Db+|cjAflcK6<_Fva4+_D--z1}3jbwz9^7?W zlR^W6e;MDK@NX@QiCP}m?Zfr4x)zH<=7n z%<}|W{B;?l!EtJAmlS@=;UEJ&;J{iy52*6_iPchCFpRznoWQMG>vPW#%T1ge2De`O z^+H%Q9VG(X$d(rkx)Qf+H5c6xj4IvAa!yzh(+6z zu!5v;h3upGaCE5EuX5S^Oq0enaQBY>^ccAEYTck;!+^2XeG6IrtA=TCsgbU8R>>Dn z;O*zey)%y~dQR$jA2@#)91BVZkAd*OUGI62jZ-ZA z2NJ0QbIy(2Y8Px4<0nty+)urKmp8x%r4USuBoRn@WZji&32)pNnCn?evQL7=u~MJ$?f_q0L*4jpXCQKnRF})xI_P>v%2ukxY961S51%@$QT<6TuXS?jCs%;wqOq&LxL+3i9m5SeVO?j6Si9!Rdq!uDIN2CTqU_P3JxD6dCo{egzXlE7s8IB# zQ{YNX3R;DXAAA;A5pN=Fl#A7btrg7tnTuNTr?sGS+ztlXq-AVs%EhqG8Lyu6qGkv0zcZau&6MhrSzX{+djTH=nJNfhsIWy zFf_d4X@W&-R=7q>VJ(zN@8~XJE9amf*e+$^rpH|nW)t%Im2v~ERZ*wBO0I@* z<*0LHdb+titBFk7Vn6m=1Ue-wFDXxExj)<&n2V4Xpan5p5N<@~%OTNbBtEvbHN;6` zOFjo|Q`y3@EkTPeUht|!OhgM5g?W*wrTZodpeVnKz)9Ffq7_u#t-pQBw6K1aXn~VX zo$XkY9X$Z~Ru`ZHf{+6{Wl=;wgku%M3$9u{&%LBL6B|#6Rg`r}A@~#7rjy3cA;IX3 zhx5e0)gK>_8bYTrg+=P#B$}lU?FYRNWRoS;-A?OQSJfk$@1_`%7HEWct@`$29COx{^f=oiVGQq0BB!D!E zf%NBrL{Y#bj|8KT?;{^$e6_K6*~Wm4{faJy`7mYfdKiZnPv?0K&2*@QOJd`BhEx-` zZsd?daXz4=@lB3HY;kQECj9^}fS1qbW`p~SMbxw#BGxn_kbDnN;ZEk$Vz$y|Zb0+0 zFt5Y&aI(zx_+6ZXF7u{Yn6D#P%~S;n!3i&n6*U4$J6X_cGDduh>OVgO7e0SR#|}lg z%Nr2?X_O0ty>znrH@_Aq$YMki5hC4qn2a@BO*5EV=9Q=qk5^v1C5<95G8BcU3RIHn z9Hu-X7#Y4m0I{~J1_2e$=1EK${3B_nT{~tf=hB6PR`_x^Qki{k#JNx2ll&*UPqG)C&1(|8Du>VZ_w(b-hz#W%5lVr|?iOc^1k)H75&JCOD@(kKTRtY)6+@j)Q!--kM zP5KQHo{8;~T^*L&9K-QCS;Et$1=_KFwr=;29MNegIQ`789zb$d<|T+x{K z{&Y{3H)DdM?DX9R@6cOOf9Bxe;0=MbcWVvxPTp(a&;za=SE@tR``t|$ap;hRHlH8j znBktx%8IDz`zKy>sV4*mL^M8`N%fZ;7NN=mKf9)S30}=4Lv+w8_{~e#Jl2dMp^G3( zy&f6Ci9EP6;@^ZAh9c0ZNmg+?`h(`uk?LV>C5V*A9O~T?7pS3iG;hp`>?4%uIIuFr zEmJyZ5!s#}4U9L~wGeg0j0t9Z;mb?}nzG+3cp#&l>iDD{9)jR)*YV5J|3ZaHsLdHG z3h4*lUTk~1LL@a}Hx?I(2fbX^zrRTpjrw_ia522F5XN}sUm0YQ6uBq;l(|o7uD{$? zyP{7wVR4|PJz+cINWH=6ewcHMOKvbd!oG_7!Ryy9Cz&Z@@vk-TP#WCApYEAP^)$jva>;tAT=4V>HjyoTWZShTtFI9+`JHa^IK{o`ayb%Qtk; zFv^AK%?b;%4)1_sZcL#WYq&da8pTy`;1BBg>AZaO3|oqv(V)*d>l4YJAlAp!*fYTa zqzI`|RFnjVe#{;7GcesEkwqz_&HO0@@q>hseK}l&1_k)Gc95SYKdkrdwDN))lW${Q z$oR(OK@1cN8mY~$-0?jVUYnu7+;84X!7#+BNL665BvGwu6wFctJ~)`ghSMAGopD1i zNk*{tc&bQkmQ7e;Q?8F(&cgDkef;qL(&D+-bl`}Rw-WzaAGV)q91(1|3z!C(VeDGK z*ZIA1AU>6~pI8$(C@;etN)z&ub1b&!Bck5RePaP0fcQ*wU*y+&uZ2&*Z!)~y^fC`& zu)TVTeBIUXH>K>mV*KEZ>~%|3_#u!nelnt_g6mbustB9L1cb$w9mwqZ9fvYjJdUTe zS}uqF_gWWdAPMgNesgX*CXUK*oOM`pYLa2&<;c4AuEOz6H65DR+ z>WNLtpX+elt6m644^Tfcf^gS&+S+g2ao;7?X?dqIs4Qb)^QpOQoK+R_!ZFj@KGB1^ zu(_(KuqEA3y(^-XaeL=@x$Z&C&EHmmi8*^t#NM=PcG4zf6x6{Z;NZB_&(ARs(8rxR4_6U~*$eb>@=XT`cKqR;-&qDg2`-R9NObF1h`4ah% z0!*(gY=ex*(JlvDL(Kg$cKME@|7@eSydc4__ZLtKKl{*#HWh9LBwJ{$&}$0v{kTl4 zOE-dOh7?owTFbyg_mVg$1@5Vg+B!uG$Ss|ZapxJwBHfVs!A!O`OOGOl4P&m?ssa!C z*Mz(RGXZm-J>;7oI2;qye(%;mHoFdjW}x0t_S)m`#Ke(~uA<9&Gd)c_ohgwJU9w)F zwxlRJ1{l2rwE`5HLFA?diP~#P@}@Omr3)n*t~#R)Lx%eD3BEX2*?h@pyn`W~_UxiV znC??`Tn%kD%?3i_un1mvZ0LS)YSkuo2ZEhO9;0ihFlk*%QxX;B#H0iLa+rz+RF!s+ zX7mE@zk$IN20Yv2<`Sw;_&lE8$Y|rzXaY|hB)gz>iR~fnX9jSk##(_br-tbo>yW|- z#=rKEDCVun=0=Os!CNAczLW7#>DgwCFzPEo7D9@Y3CQ)tgLZAW#m$x8LsiUp5Hry2z-}0H5-h#;K zkTWvA(@}F2cMMVK*W)aX4UkuC%N9dd5s=Ur7L=I=l6bgXr(*0HuY=invQCn5{Bg&B zgOwX|BRtfSgrFDEetm9WwjeXM##h9j7UoCY)}wuTw-q&4QyDzod9rfLQ-AUqxEMu$cS%mmHIScgJZIh!S`h zmoLGJ52q}gzCr3FRWp<^swpeN-0aHToQ6ZOo3of^YKUs|TT(Js^q7Pxf&$c#1q<|p zewsT2I{8|;3*SC@grsfnPC$2Z@K6c6x9@tdU#j9R;48uZZKoi^RY30iY!?KrT#u%_ zqm9I?W#bR)-n3z_M#hL8ai(#;`mAL}3~GwP+*y(7u7Q-tR*UZGYe@*|d|Q$Vo!z!? zL1r&8(?L^^Sw@!5-=nh6hijWuOqMl|#E9Pe7J-f?G}pS!8~IzBk3Z5oDt_fxoT7;h zPvIA7@OVp>1iMp?0K{6K+jsLZ0a|Svd?>2beGXw#XrdR-dO>GxYU@J1N{0Xss8ym& zj!H_URxpw!Bv%E^GdDqU#$f4_?Y2lkMYF+{0UP7EwJ1bM}x zb{1s3XQxzaMn|4UIjtZ>=>N=uD6kyW%1_aV=8yS>K9cs9Elxw7nb>^FH}V=jqQNWZknaVpLQlktRuC?D$9_fe%L379oR4|Hox$6xSyk* z>`%)wn>{L0aHr_EGU;ONg_%|W3_@`zn5B@@UB~;wVUmO5yLV|?9c0MjTq+M$a3gJR ziB!v?`6>=d3M07%d6xmMo%$TUVM|4B(}VTkl(cv?%`f~|+#n9g98n1|W0Q_Y4C!bY z%KLRgN!_~@-(0%C^sM6<+h$k|+mSuB0~W*!LJXOn!j~p5b)XkSa28W6^2B5-)hT91 zsw!dJddz3d_M$ZFO84$eG`jccC%-6gq{kGOam?PqBa%To$!cNexarUBU!!xDAG7;G zuT7EJCM_#;_%$MENTM(7Yv!+*n*k^^Aa#sN^up-ry%M~hWgu7)C`E+e9p`WkbvRXG zxIMyGf%)N~_!hVwC_a3ctkT$1U^KRF&Wt99adr@<^8M+=*p(DpDePsN>wbn9^b{rU za8Hs+RoWy!t*lWCsyY@sVV-&ws^k2i>_?zF$Jue)b7FFYFIXy&q|>fODqHsYP}R0C zyl=fMC{B$Rf!lxvIP-D5;}&r%<-@f;i16GPC^G2F{SK3=n*?PDEJ6PurN}R0hX8C6 z?yhU5_ig%Vool&LC$+{O|r!I{KC{_Sy-HBjT zl4)BIz?BR`a`WHza*b3sSe{)`6lxF+B}ftOQd}iE2;R{2=W*}~?FV=eP}`Si>tJIp z{zH0;?pz)9{!!Q|UonOUPkIm`DpXmOC21l84B{`N{kcU+2sK3L{ zbj*Z};68|z!)Rgi&lZk(Ir98Ww$f2`+lL;Xkpv&{OSFR&R|BgY>^+ZbX`~z?8{vGZ zKBz4FT)a-!Ly?6voUPcS9Bt}9V_wQdh9gnBLw%FhKbK;tp4tbMpmkemCrMiap;UpM zHUPzxK9v`iJIm}EVE2r_q1s_$?KrS-ZHO@?)ScW%^AM(<06Wk!eGgP#@*_~vJv-@) zZ7%(ay5C=OWJglpkddkJHwF`R6A~46$c1_u3Ig;~v&Juy!5!%-;20X~NUAoZ@?fOb z_qqpDo4w7)o~ldskrVra7EA4nV6DZOKGBJ-cn7mBg*s-h0;-PsCJpQq9jKAQBb_8q zE^f6WK=rb_=0LglJZV%8IwNb`qc=Ivtyh4_ z%jBo%#PC#a%TlPi(Xr%Z9J8>snV=<8Rn6rdZ8Z8S)TaCsUa=-5I}f zk=m3hjgtwN85RwNX^~_)Q}uDohLoAM;JC$BWkt{-^{0l_Em*>2`RnM;aJf%)${*|! zs&$O}fW&AX;ci9m#CA1+o&C0x9b{g}^}i_zj*&!6$u3KXL;rA*(@VjKUzbHrdDs$_ z7inP(u<~J5n8}r*XS{DMKeFAr-Vh$dgl3!XqLJdot_+ZMM&TmZc+JNMYR<=f>JZS& zB63~auDihUl~KlP4VsvCNSSLq9(`4X^)yPGElC$g>ouD0x|(tHas4yeeRz$INrZ9w zWORf_h$Ycnzj>x{pUB-lm<@fQ{OTc_Sk{zM(MlcC^fqMbv1{2S>wl1z>+!7F@Pw_a z9ZQW3!(k*yG40wlk`J{Xg{Jx%yJaRN>kLR5=4RnhoxRLWIlK40Y|XtH z_T6e*8TcHxEN}%@Sq~+#5GoCeU&iv$_F0;! zt?-Hi*c6Es4d0f*-He<^BKO3}nHtII zcm#_wvFP#DS7gdVl_~MG{U4$qU3$EsX|-XPRSE^JUq@?7d8;~D?)eYA5%d{TsWSDq zCqy3f7EDUhDbbu5^@ZwcIqAAs;~>q++<6Gq6zmo#Iub$LKU^L3wP6iKxo3D>J^~!h z$oL^s;A;@L*6ewbcEd-|H6)`rim#B z+KO}*a1n`U4fG&2c44pZwfl0#^x2^cDoqLoG>ID}$4&c-%(Yt?}hk9{Na8D&#(_pnOxXb%+UwB;BoF!bfGlK{wGcQyDSSgYv{R0|MsBbFrKE2BR=d(H*M z`Af&7X#Kty-0%>}jBsw90>AJrHi9`>%KfCC9{|1<0b#UWMH96=+cFv?1HEvM_F(TU z$L9_nzI__t1GQOl-59}qC8R&|j@R(RXqVNjSZa)) zHBhHw`ZIC)L?}z6a(qxB|43Y3@d_TAV+=YBf|Kyll!o9z0pDFi@QlsAUQSl>1NR&? zAM5)7%3^7T`q@}VIeJ$)7deL1nbYBs9 zOlGy_R7{2Lr&5?j@Z($)tu*i|x72D*vGggvCp^`sX&cgH!(9;uyHgT1$$6rCJsu%G z$$*Tl5lmB{w-5#KCm91!_VA%AlwEo<+@Kuz1HffhZ*Bgx`Z)9J|&JRnD5O1uyb zcAOh9a%-pxaKUF!2Vg#vR$-+0!;>#hZbKL`)oF%VBbV8Nja5<@hOshgFz_Dc1dq=d z#9Mlwlu)D9*N0;;fuEB%qMWH{tgg^m-(az@Zf(A>_m)sWZ^wVgqBmxVnj@NgG;ZFc zV`m+7BiopW*;(8NAJMc$3&Bxy{n5`)sU%FpGT<=qZ;yFA2v=X{sGR($N6`$%LKn5c zhL?s<>&q*cOXk|9Rdt{n&_CpC+vgTOiyV*|jnBEq_^aG3m2W8#k!UQ_B-sp(Ei3o& z-E>faG(HY-6r)Xv0Na~07R}3;?ifde`Jgo>OXyZ%ksCt_#w8d*228ur^iR)3ZoQ1! z)G#ia-1AW&QrsYDt`{3f=x5$?NUwdXX>pN%e(V6n>y}K) zsFopL9dzykC8R%KUM97=?zp=8zKQLa|ZN~W+osDr^+8`TxjLyFoNs$Tkzr}GQp z5Q9>Qnv|VsYA{?QPw%SCwJVNB-9H7FoAz31Inq%7s){}96 z?cQyn#o=(Q!d2e>k3c_<`6QyY;x}8Mo;^TuaCK-aMp#acK-a}*E%)5f&7^vF-N1R6 zzt42qu=|@YBW2?JS(Fg{F{E1S19MMt-K#I={h*Hy8amZ8V+>9cFQ}t>=QaQ9M7>N+ z>)7|h_X)H^$N_?aFJHC!9@A-DamVvUqQmLavs>cY>>~^$h+UVitD5tmC&k@xN!{Ji z=kKGbI0)#3a>-|3ZH|0#rfK5Z#1EykzVHT}Uw^i*VL98MRtlhMe3sA0J|^<+_H96M zP_Khgvl(IJH*fZ(Jf!6_rsHp|(4QDvF&8P@v`GHW#}isR9-bLIFaf75a=Xm<>U3Hxy#y#juR>) znI9%0a}%_#;O7=z(u-WBwJf_74CK~i_9FT@`1J0zvX7vJ`|ycJs_8QFRAhjwdFmo= z%S-zAavWY=mYATwMh0d}ck);SklpoIAZ- zqJ<3n9{INK`FZ)r27M=`e4lZNe%{XS&zD2{e|~;OUrYN#r6!TLzkH=`LZM53I5>?X zFd@RdK=6KhxH;?ZI0i6Uw3#Namk@o66#Q~YPp!!`#P!x`$S>Hz6R)E;YxIRAYb7q4 zmB1xSfUDcDullant7*!V15uQLw`e)cR%e8M)}|6Yti_-*-58$mdWl~KbPTqNTLh1Tpqr&$VI)8Zm@A0 z=W5Mr$jZpZ_*M<)aSVB_QrE0u!YRKOUVJ69pOTx)eDUhjnb2r}l=)-}B{JA(FTy+z zwQ-wJ;-$#-ZC)X4U%bk!c{##YS08kw+p6A*L+4;Wt=R(gR@U{~;3>Vd$p0;2j-z1= zBb=VarjH-c=l1KACl0elmigWHR6e6*F|vLH4IWyGSLX6RNO3zOr(DgfqSXXqz}fvE_C$LNUQ&`y=Ho|Y!8KI#%s?1fud zJ#m%c&FD?OmOIy$EPm)2Z`qw+cqim`h$iDmPNbQ^^W?kp z4s{@P$a#BdME2Ude15`KzYUhO{2WD7%F#6-3~&-@>dcVnUD7^foyRBFJ@AwDoheH< z{z-k!#ezbShqmnD_|2(_ec@|VH}`7cPpPTdTBhrv*=DQ<_)jvKU8Qg9_;O?f{9BbC zBnQ&UC$c%%&kWl7V2NP)^Na{j42ZAIllfMFd=pCVZ$sQAN=*^ihFRndVlnE58u}qA zc-Oc0sL!rvSzc@GFQOwv%3dl;U$0k7WXLORX+t&-o}`}y0A~&O`mU)qA2ljxxGL~3 z_1IKZ1_IkZLsm<$q0o~;rXJJlTqciWbzV)M1=g`W?#yfdaiAj<+D2bBO*tp6D(yCw z;DE21zRg~t4D=!OYjh68uh93UJwT}O&)2h0^^&i9yfRqJ=2yNFyyc}J)=K&JvHW5zTPY3}6=3LS?;6ZQJo zODOVsGIJnJ2?mgiZb$T6Q{voHTW&21 zli3_cKh%BSBCGN1$i>|~%xq+R=zh`P`LboWHb+$N(y!+0=(bk$>6I6Jo+4;y!3bQ< zL}PMy`d-Y;R}-T3O1_l(ZTR4&se_EgERoB?Yj0RYmb-9|#IR6-yW3_Zhvi~7P>LIe88Dn;jU#qKUxBC79Y^su_~jREfPs1i!B=*by{Un%W4^Y zcu)_=#L_S$q>r4nRL=;mS`IS{<)73#0qRDVasmAbn;sE+>)1EdJHhjqL#Fr>yA4a+ znfoA5-ERYR(jRV(vnyPdDZ*O$X@d-&YL#L8D;(HHr@XOWyC^Y4?p$Ltho$zOwL#() z9IuXeiS)AJk`FTYZn zNLk|pM%LLKFLy&T>cn#BPF$sp3p}MvkIl54cDm-N$#H9Ek-^EgP`jRet2dR0o`2Mw zN;9qRAeLG+RP-J6uFI0W<)2cGvr2qXvb1{#N_DC44yaW4IjXD<*>xB)kB6yYBi&5p zQ4c(*o4B|p#h4&_`m^mmLSW{AEFnk0MR!hWc?uW7OP?fJ(sK{PV9gR6 z60XKWmH6<@8|Dp2e2dA!%6^_y;CjCx7dN~9*XUUvmWIJsI7YO!nsk!!<8r`|F^^kB zi~h0ldfVN*iz+V$zr5zwK;k8I_ zztrT{P5ckAb_=-3QU@_EN7qkeHb6|%gKGGlN^$vkg#w?2Xq9$cTN1$%N$hnU;CmNj z$L!r73k>kULu@$l3g%+0EP6&#VRL#e_(qd&FvUUT9abVyzcEHG8xk9+!gZS0u7dxw zJcUN##nOw53=8BIo)waFa3P-eeK%rg>R?NG#$Pij%ixlxXEV^RKRLA6It1b1Lg>j5 z83{)pM~sL!o8hDBg-3cDcAqbAH}w~2Z%OhnHbyRrU{Y2!$9AF?UV*_Z!kMAvkvCzfI}R;H?K(zO+k>Wp)!W!fnVew3yHP{$ySKO@Kie4 zIvfPe;c*}KD4Z!b9l{{MbkvaBGbz~6R1fD6sVmJbIzZg95nNzEY*1bv*9CsK+zb2} zGC6(fMnmGVr<@rzkv1YCe2UkQs!$H|ISV(bPuxg29Yc>=d8eV=ryDYGlun{%?jJN0E)7Q7zlhFmE-bCK^>UMVJ z)j(a<;1b+j`^VO@b#?VAe&nraEs5%1H~eb(=%JsOg%(6!M0+sKWM-E^UZyHTu=waiFS;FequXIxqmTdkfCL?4_4#e`nf&@%e%^lC$*;} zi{l~|f-9h&pHUb|v}h+KWqyIpVA{0fos6VLn _6#>GhSg{GVhP_1Jv;fp_LJL zW=7g;_~4*x762^(N<9`X06I7<@B`id#k1~uAy6=C@Rv<^rVxOzF;lf3kN}c8Oq?a> zKRF@jZIo*`er>D^=*&lIXmritTY#V;=b>Uey?d$0<6gwNZeU)qKj7EvGJ7Gct(Yzo z8fU=MDB53r9z;ChuaC$cEGXn}l>%wJ~KF{Yzs*A$d%5t{( zQC#tTAYE}xS(f~$b4`lVNx+uGw(eE^pY^fs+2zk`bdGu@r5D&J9gMbO_y-jyYh8H6 z@as*~Rzea#YzVlnOIWuhvb1V}k ze@L(t3hzADk_RyVGHyXruZV9~%<9XRZl(Wl8ap=YF|NrHvuKC-;W{+_GX@z$^{Vam z28nf$7nT(3uy2TFZV?y^B}jo$`a3hT5q=$;bF`t}z4Ol(`HwucL>Bt8=;TNgm^W@9 zBW}~pQX2HjH9o;h3k9`)7dFqOrS8GfOPF^x1tMhiI?7RLJzc-fO{XXut`btaT=6eD0EqlW&x*7!^)$GWf> zUdXRr7Me!xscYTZ_L^H}-+wVa%X$Y-#}9UIqTdCIjy%<$kv*kH@QW`veZ#Sq<&yKf z{;#ed8xr_|w)2fvpS>KWif=8XR0haCDA=Uzo`;Q0>?=%DSrtT+kzi)bf2l3DF!dUZ1Vw0JJO3TU-BR*?P_=s)Wx_K_6`t1& zFO`pnihiB8OqA#d;4*-jy(41TzB`Ag{TdfsOVz1<$tGzSUt=Awbt=|`PegE&6|0Okb>_|ZHkJcSvT@LHf4cbHls=4JlUf9d7 zz6HM+J{SVB6BI>AKjO6^EJ7-J)DfXam!e&WXF{Gk-Gmt`8z{Aolh^qkd|biFW{=q? zpOrSzS}-PX2qiN^&YSUUQs*ex0-&Yh-N&4We`30?bR65qGJtXfin|{4an@7+}7#_ z9kcFg61&|~mZiy(_yWF6VLAvl>%()hhbwD(Rl%7E+N-|0xxY$gI^1MMOqtJSgt^u7B5cT2P#S6U*V8bdzbnOVJ+5k^%~r)ty0+II zSN-d5_2$0lqtf;RYHL?EF_N4fr`W9SZ{ZW38CJjB?-HsC6xOhC7Zy7OFvNB(L2MY_ zTHkk{Z)mqtr>PCJGiGWS_PV@HbRZe#Y)k~SHz74dSUW#==QjEap;RizChPy~C4%?h ztqq*WCBvnlun)7*)c#Uk)e0v4<6EvOW^w$63X(qb-^z$eO+G=BktMt96;!nPO*^)8 zf{_~IFD$%s)fCy$ICrIDhawSFjUB5nCQF6VgeCFwbuA4HtY-c}^rT900@8W45 zqTPm5#X)Us-}4=~X=BmxJaNLxDp-|OSBw;lvj)(3_;J>BJO%ZehIW;4pKPVhJ_Pu4 zw1nq74Q|a0T8)2bh=;2To+Fd zJ2hwRBYUmU;$OS(zl;nGZj229w*upVgv5kvYrqmp%+#7UrZ*;-&6`C;XWWawiW~B9)L&VS6YdSOyAf3ujTi^gZHU&;Fqs2bRy?Fkb`I` z(o!w3=U)D}vjd9TNHY!mh0b=|EBxK1v7pdPPNe=w3#leOb+{zfv$Kq29xb{&#$s<# zhyOo)K-KTgiwo={;LsiX`{0{3 zIWVyNNh_rL{T8C^i+r$OBpMO0-#XNW27Hr!5B&aLzxK301&aR-{k?H{&_7^?2@vAt zW_}O!^7=07_jL1W>Kq0(KKM1~mOS9KRbl#hg0w1 z;qe4$wTTN?45T{Dep?0LpT72|)+;efI9>!>%DA=kN3i_q`^dN{rrrr-Keg+xLbr Date: Sat, 5 Oct 2013 16:05:43 -0400 Subject: [PATCH 023/101] Implement subclassing of AccountElsewhere This required some modifications to Postgres.py (released as version 2.1.1). Now we can have a subclass of AccountElsewhere for each platform we support, and querying the elsewhere table will return an instance of the appropriate subclass. --- gittip/elsewhere/__init__.py | 27 +++++++++++++++++++++++++-- gittip/elsewhere/twitter.py | 10 +++++++++- gittip/models/account_elsewhere.py | 15 +++++++++++++-- tests/test_elsewhere_twitter.py | 7 +++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index a05e542d1b..ab562ec3bb 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -5,15 +5,23 @@ from aspen.utils import typecheck from aspen.http.request import UnicodeWithParams from gittip.models.participant import reserve_a_random_username +from gittip.models.account_elsewhere import AccountElsewhere from psycopg2 import IntegrityError ACTIONS = ['opt-in', 'connect', 'lock', 'unlock'] +# Exceptions +# ========== + class UnknownAccountElsewhere(Exception): pass +class BadAccountElsewhereSubclass(Exception): + def __str__(self): + return "The Platform subclass {} specifies an account_elsewhere_subclass that " \ + "doesn't subclass AccountElsewhere.".format(self.args[0]) class MissingAttributes(Exception): def __str__(self): @@ -21,6 +29,10 @@ def __str__(self): .format(self.args[0], ','.join(self.args[1])) + +# Platform Objects +# ================ + class PlatformRegistry(object): """Registry of platforms we support connecting to your Gittip account. """ @@ -29,7 +41,9 @@ def __init__(self, db): self.db = db def register(self, Platform): - self.__dict__[Platform.name] = Platform(self.db) + platform = Platform(self.db) + self.__dict__[platform.name] = platform + AccountElsewhere.subclasses[platform.name] = platform.account_elsewhere_subclass class Platform(object): @@ -41,13 +55,22 @@ def __init__(self, db): # Make sure the subclass was implemented properly. # ================================================ + expected_attrs = ( 'account_elsewhere_subclass' + , 'hit_api' + , 'name' + , 'username_key' + , 'user_id_key' + ) missing_attrs = [] - for attr in ('name', 'username_key', 'user_id_key', 'hit_api'): + for attr in expected_attrs: if not hasattr(self, attr): missing_attrs.append(attr) if missing_attrs: raise MissingAttributes(self.__class__.__name__, missing_attrs) + if not issubclass(self.account_elsewhere_subclass, AccountElsewhere): + raise BadAccountElsewhereSubclass(self.account_elsewhere_subclass) + def load(self, username): """Given a username on the other platform, return an AccountElsewhere object. diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index 70bf5599ac..de599e5555 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -7,14 +7,22 @@ from aspen import json, log, Response from aspen.utils import to_age, utc from gittip.elsewhere import Platform +from gittip.models.account_elsewhere import AccountElsewhere from requests_oauthlib import OAuth1 +class TwitterAccount(AccountElsewhere): + + def get_html_url(): + pass + + class Twitter(Platform): name = 'twitter' - username_key = 'screen_name' + account_elsewhere_subclass = TwitterAccount user_id_key= 'id' + username_key = 'screen_name' def hit_api(self, screen_name): diff --git a/gittip/models/account_elsewhere.py b/gittip/models/account_elsewhere.py index 80ddac0bf2..d3f38d99f4 100644 --- a/gittip/models/account_elsewhere.py +++ b/gittip/models/account_elsewhere.py @@ -3,13 +3,24 @@ from postgres.orm import Model +class UnknownPlatform(Exception): + def __str__(self): + return "Unknown platform for account elsewhere: {}.".format(self.args[0]) + + class AccountElsewhere(Model): typname = "elsewhere_with_participant" + subclasses = {} # populated in gittip.wireup.elsewhere - def get_html_url(self): - pass + def __new__(cls, record): + platform = record['platform'] + cls = cls.subclasses.get(platform) + if cls is None: + raise UnknownPlatform(platform) + obj = super(AccountElsewhere, cls).__new__(cls, record) + return obj def set_is_locked(self, is_locked): diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index 526b3654d7..04492f25c0 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -39,3 +39,10 @@ def test_account_elsewhere_has_participant_object_on_it(self, hit_api): hit_api.return_value = {"id": "123", "screen_name": "alice"} alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) assert not alice_on_twitter.participant.is_claimed + + + @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') + def test_account_elsewhere_is_twitter_account_elsewhere(self, hit_api): + hit_api.return_value = {"id": "123", "screen_name": "alice"} + alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) + assert alice_on_twitter.__class__.__name__ == 'TwitterAccount' From 7dbd70b815f9dfe7e51326e31022111390f889db Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 16:44:33 -0400 Subject: [PATCH 024/101] Get Twitter page barely working TTW Many tests are broken and there's a lot left to do, but there's light at the end of the tunnel! --- gittip/elsewhere/__init__.py | 11 +++++- gittip/elsewhere/twitter.py | 31 ++++++++++++++-- templates/sign-in-using.html | 8 ++--- www/on/%platform/%username/index.html.spt | 43 +++++++++++++---------- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index ab562ec3bb..bbc462ea61 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -30,6 +30,12 @@ def __str__(self): +# XXX The old platform modules want this, but is it even used? +def _resolve(*a, **kw): + return None + + + # Platform Objects # ================ @@ -40,6 +46,9 @@ class PlatformRegistry(object): def __init__(self, db): self.db = db + def get(self, name, default=None): + return getattr(self, name, default) + def register(self, Platform): platform = Platform(self.db) self.__dict__[platform.name] = platform @@ -112,7 +121,7 @@ def load_from_api(self, username): # ======================================== user_info = self.hit_api(username) - user_id = user_info[self.user_id_key] # If this is KeyError, then what? + user_id = unicode(user_info[self.user_id_key]) # If this is KeyError, then what? # Insert the account if needed. diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index de599e5555..c187bc905c 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -13,8 +13,35 @@ class TwitterAccount(AccountElsewhere): - def get_html_url(): - pass + @property + def display_name(self): + return self.user_info['screen_name'] + + @property + def img_src(self): + src = '' + + # https://dev.twitter.com/docs/api/1.1/get/users/show + if 'profile_image_url_https' in self.user_info: + src = self.user_info['profile_image_url_https'] + + # For Twitter, we don't have good control over size. The + # biggest option is 73px(?!), but that's too small. Let's go + # with the original: even though it may be huge, that's + # preferrable to guaranteed blurriness. :-/ + + src = src.replace('_normal.', '.') + + return src + + @property + def html_url(self): + return "https://twitter.com/{screen_name}".format(**self.user_info) + + + @property + def nbackers(self): + return 0 class Twitter(Platform): diff --git a/templates/sign-in-using.html b/templates/sign-in-using.html index d0dcd6638c..8052823f71 100644 --- a/templates/sign-in-using.html +++ b/templates/sign-in-using.html @@ -3,20 +3,20 @@ Sign in
- \ No newline at end of file + diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index e3aa5d71b9..85b5c3d3d0 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -9,27 +9,34 @@ from gittip import CARDINALS, db from gittip.models.participant import Participant [-----------------------------------------------------------------------------] -platform = website.elsewhere[path['platform']] -username = path['screen_name'] -account = platform.load_account(username) -title = username +platform = website.elsewhere.get(path['platform']) +if platform is None: + raise Response(404) + +account = platform.load(path['username']) +if account is None: + raise Response(404) + +title = account.display_name +locked = account.is_locked +participant = account.participant [-----------------------------------------------------------------------------] {% extends templates/base.html %} -{% block heading %}

{{ account.platform_name }}

{% end %} +{% block heading %}

{{ platform.name }}

{% end %} {% block box %} @@ -42,16 +49,16 @@ title = username {% block page %}
- {% if account.is_locked %} + {% if locked %} -

{{ escape(platform.display_name) }} has opted out of Gittip.

+

{{ escape(account.display_name) }} has opted out of Gittip.

If you are {{ escape(platform.display_name) }} - on {{ account.platform_name }}, you can unlock your account to allow people + href="{{ account.html_url }}">{{ escape(account.display_name) }} + on {{ platform.name }}, you can unlock your account to allow people to pledge tips to you on Gittip.

- {% else %} @@ -59,11 +66,11 @@ title = username $(document).ready(Gittip.tips.init); -

{{ escape(platform.display_name) }} has not joined Gittip.

+

{{ escape(account.display_name) }} has not joined Gittip.

Is this you? {% if user.ANON %} - Click + Click here to opt in to Gittip. We never collect money for you until you do. {% else %} @@ -82,11 +89,11 @@ title = username

Don't like what you see?

-

If you are {{ escape(platform.display_name) }} you can explicitly opt out +

If you are {{ escape(account.display_name) }} you can explicitly opt out of Gittip by locking this account. We don't allow new pledges to locked accounts.

- {% end %} From a891e82d0dc7e62c790f5ce906a41a8596890c50 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 16:57:01 -0400 Subject: [PATCH 025/101] Reimplement nbackers --- www/on/%platform/%username/index.html.spt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index 85b5c3d3d0..00d038510d 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -20,6 +20,7 @@ if account is None: title = account.display_name locked = account.is_locked participant = account.participant +nbackers = participant.get_number_of_backers() [-----------------------------------------------------------------------------] {% extends templates/base.html %} @@ -35,8 +36,8 @@ participant = account.participant
From 41954423c6240e25682b5643fd6b7a1f2a3bc1f2 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 16:57:40 -0400 Subject: [PATCH 026/101] Prune crufty nbackers computed property stub --- gittip/elsewhere/twitter.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index c187bc905c..7a6462e760 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -39,11 +39,6 @@ def html_url(self): return "https://twitter.com/{screen_name}".format(**self.user_info) - @property - def nbackers(self): - return 0 - - class Twitter(Platform): name = 'twitter' From ed4dee6a487ddddb88bf746caaebc241d8761e49 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 16:58:52 -0400 Subject: [PATCH 027/101] Remove cruft from configure-aspen.py These were used for their oauth_url methods, which will now be on the platform objects in the platform registry on website. --- configure-aspen.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/configure-aspen.py b/configure-aspen.py index 28f9e87bfc..6d75918656 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -84,12 +84,7 @@ def x_frame_options(response): def add_stuff(request): - from gittip.elsewhere import bitbucket, github, twitter, bountysource request.context['username'] = None - request.context['bitbucket'] = bitbucket - request.context['github'] = github - request.context['twitter'] = twitter - request.context['bountysource'] = bountysource stats = gittip.db.one( "SELECT nactive, transfer_volume FROM paydays " "ORDER BY ts_end DESC LIMIT 1" , default=(0, 0.0) From a7589c3fafe7c55ef74cb18f7e78a67b1c4dd9d9 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 17:04:21 -0400 Subject: [PATCH 028/101] Rip out _resolve Afaict this is straight cruft. I don't see any actual usage of it in the codebase right now. The old version of it took a platform and username on the platform and returned a username on Gittip. That's redundant now that we have participant objects on our account elsewhere objects. The one thing I thought it might be for is redirecting account elsewhere URLs to Gittip profiles in the case of claimed accounts. I implemented that here. --- gittip/elsewhere/__init__.py | 7 ------- gittip/elsewhere/bitbucket.py | 6 +----- gittip/elsewhere/bountysource.py | 6 +----- gittip/elsewhere/github.py | 6 +----- www/on/%platform/%username/index.html.spt | 3 +++ 5 files changed, 6 insertions(+), 22 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index bbc462ea61..1b6bcafd71 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -29,13 +29,6 @@ def __str__(self): .format(self.args[0], ','.join(self.args[1])) - -# XXX The old platform modules want this, but is it even used? -def _resolve(*a, **kw): - return None - - - # Platform Objects # ================ diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index c951c7f03a..aa824ee7ce 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -5,7 +5,7 @@ from aspen import json, log, Response from aspen.http.request import UnicodeWithParams from aspen.utils import typecheck -from gittip.elsewhere import AccountElsewhere, _resolve +from gittip.elsewhere import AccountElsewhere BASE_API_URL = "https://bitbucket.org/api/1.0" @@ -19,10 +19,6 @@ def get_url(self): return url -def resolve(login): - return _resolve(u'bitbucket', u'login', login) - - def oauth_url(website, action, then=""): """Return a URL to start oauth dancing with Bitbucket. diff --git a/gittip/elsewhere/bountysource.py b/gittip/elsewhere/bountysource.py index 3fbb893dd4..2213562eed 100644 --- a/gittip/elsewhere/bountysource.py +++ b/gittip/elsewhere/bountysource.py @@ -2,7 +2,7 @@ import md5 import time from gittip.models.participant import Participant -from gittip.elsewhere import AccountElsewhere, _resolve +from gittip.elsewhere import AccountElsewhere www_host = os.environ['BOUNTYSOURCE_WWW_HOST'].decode('ASCII') api_host = os.environ['BOUNTYSOURCE_API_HOST'].decode('ASCII') @@ -15,10 +15,6 @@ def get_url(self): return url -def resolve(login): - return _resolve(u'bountysource', u'login', login) - - def oauth_url(website, participant, redirect_url=None): """Return a URL to authenticate with Bountysource. diff --git a/gittip/elsewhere/github.py b/gittip/elsewhere/github.py index 45a16da3e5..aaf27709ad 100644 --- a/gittip/elsewhere/github.py +++ b/gittip/elsewhere/github.py @@ -8,7 +8,7 @@ from aspen.utils import typecheck from aspen.website import Website from gittip import log -from gittip.elsewhere import ACTIONS, AccountElsewhere, _resolve +from gittip.elsewhere import ACTIONS, AccountElsewhere class GitHubAccount(AccountElsewhere): @@ -18,10 +18,6 @@ def get_url(self): return self.user_info['html_url'] -def resolve(login): - return _resolve(u'github', u'login', login) - - def oauth_url(website, action, then=u""): """Given a website object and a string, return a URL string. diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index 00d038510d..d0393bc8fe 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -17,6 +17,9 @@ account = platform.load(path['username']) if account is None: raise Response(404) +if account.participant.is_claimed: + request.redirect('/%s/' % account.participant.username) + title = account.display_name locked = account.is_locked participant = account.participant From 1a74332488bb4d20ba13c7ab7c1c98dedae5f5b2 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 17:16:05 -0400 Subject: [PATCH 029/101] Start in on oauth for Twitter This gets us the first step of the way, need to unravel how this is supposed to work again. --- gittip/elsewhere/twitter.py | 2 +- www/on/%platform/%username/index.html.spt | 7 ++++--- www/on/%platform/associate.spt | 13 +++++++++---- www/on/%platform/redirect.spt | 7 +++---- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index 7a6462e760..f9766abadb 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -97,7 +97,7 @@ def hit_api(self, screen_name): return user_info - def oauth_url(website, action, then=""): + def oauth_url(self, action, then=""): """Return a URL to start oauth dancing with Twitter. For GitHub we can pass action and then through a querystring. For Twitter diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index d0393bc8fe..6734bdede6 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -24,6 +24,7 @@ title = account.display_name locked = account.is_locked participant = account.participant nbackers = participant.get_number_of_backers() +oauth_then = account.display_name [-----------------------------------------------------------------------------] {% extends templates/base.html %} @@ -62,7 +63,7 @@ nbackers = participant.get_number_of_backers() on {{ platform.name }}, you can unlock your account to allow people to pledge tips to you on Gittip.

- {% else %} @@ -74,7 +75,7 @@ nbackers = participant.get_number_of_backers()

Is this you? {% if user.ANON %} - Click + Click here to opt in to Gittip. We never collect money for you until you do. {% else %} @@ -97,7 +98,7 @@ nbackers = participant.get_number_of_backers() of Gittip by locking this account. We don't allow new pledges to locked accounts.

- {% end %} diff --git a/www/on/%platform/associate.spt b/www/on/%platform/associate.spt index c2eab82562..cfd4dd81a1 100644 --- a/www/on/%platform/associate.spt +++ b/www/on/%platform/associate.spt @@ -1,12 +1,17 @@ +from aspen import Response -# ========================== ^L +[-----------------------------] -service = website.elsewhere[path['service']](website, username) +platform = website.elsewhere.get(path['platform']) +if platform is None: + raise Response(404) + +](website, username) service.handle_oauth_callback(qs) -# ========================== ^L text/plain +[-----------------------------] text/plain Caught! -{{ qs['code'] }} \ No newline at end of file +{{ qs['code'] }} diff --git a/www/on/%platform/redirect.spt b/www/on/%platform/redirect.spt index 0b61591ab6..1078933533 100644 --- a/www/on/%platform/redirect.spt +++ b/www/on/%platform/redirect.spt @@ -1,8 +1,7 @@ -import requests import os +from aspen import Response - -# ========================== ^L +[-----------------------------] service = website.elsewhere[path['service']](website, qs.get('username')) @@ -18,4 +17,4 @@ redirect_uri = '%s://%s%s' % ( request.redirect(service.get_oauth_init_url(redirect_uri)) -# ========================== ^L text/plain +[-----------------------------] text/plain From 6c8be36a5fa7fdaa5b5be3007f8f73233b0773b4 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 20:46:27 -0400 Subject: [PATCH 030/101] Rename website.elsewhere to .platforms I'm sort of struggling with the nomenclature clash between elsewhere and platforms. --- configure-aspen.py | 2 +- gittip/wireup.py | 6 +++--- tests/test_elsewhere_twitter.py | 2 +- www/on/%platform/%username/index.html.spt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/configure-aspen.py b/configure-aspen.py index 6d75918656..cdd5eaa9a7 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -26,7 +26,7 @@ gittip.wireup.mixpanel(website) gittip.wireup.nanswers() gittip.wireup.nmembers(website) -gittip.wireup.elsewhere(website) +gittip.wireup.platforms(website) gittip.wireup.envvars(website) diff --git a/gittip/wireup.py b/gittip/wireup.py index 8ccec21fa4..08caff49a3 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -82,9 +82,9 @@ def nmembers(website): community.NMEMBERS_THRESHOLD = int(os.environ['NMEMBERS_THRESHOLD']) website.NMEMBERS_THRESHOLD = community.NMEMBERS_THRESHOLD -def elsewhere(website): - website.elsewhere = PlatformRegistry(website.db) - website.elsewhere.register(Twitter) +def platforms(website): + website.platforms = PlatformRegistry(website.db) + website.platforms.register(Twitter) def envvars(website): diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index 04492f25c0..3303863e2c 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -12,7 +12,7 @@ class TestElsewhereTwitter(Harness): def setUp(self): - wireup.elsewhere(self) + wireup.platforms(self) def test_twitter_resolve_resolves(self): diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index 6734bdede6..c1943cfc91 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -9,7 +9,7 @@ from gittip import CARDINALS, db from gittip.models.participant import Participant [-----------------------------------------------------------------------------] -platform = website.elsewhere.get(path['platform']) +platform = website.platforms.get(path['platform']) if platform is None: raise Response(404) From efbc39c8c920e577e9e7d8850bbca831bf9cd952 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 21:15:24 -0400 Subject: [PATCH 031/101] Start fixing up tests for Participant --- gittip/elsewhere/__init__.py | 38 ++++++++++++++--------- gittip/testing/__init__.py | 6 ++++ tests/test_elsewhere_twitter.py | 6 ---- tests/test_participant.py | 8 ++--- www/on/%platform/%username/index.html.spt | 2 +- 5 files changed, 34 insertions(+), 26 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 1b6bcafd71..c9e45eb178 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -42,6 +42,12 @@ def __init__(self, db): def get(self, name, default=None): return getattr(self, name, default) + def __getitem__(self, name): + platform = self.get(name) + if platform is None: + raise KeyError(name) + return platform + def register(self, Platform): platform = Platform(self.db) self.__dict__[platform.name] = platform @@ -74,18 +80,18 @@ def __init__(self, db): raise BadAccountElsewhereSubclass(self.account_elsewhere_subclass) - def load(self, username): + def get_account(self, username): """Given a username on the other platform, return an AccountElsewhere object. """ typecheck(username, UnicodeWithParams) try: - out = self.load_from_db(username) + out = self.fetch_from_db(username) except UnknownAccountElsewhere: - out = self.load_from_api(username) + out = self.fetch_from_api(username) return out - def load_from_db(self, username): + def fetch_from_db(self, username): """Given a username on the other platform, return an AccountElsewhere object. If the account elsewhere is unknown to us, we raise UnknownAccountElsewhere. @@ -101,22 +107,24 @@ def load_from_db(self, username): """, (self.name, self.username_key, username), default=UnknownAccountElsewhere) - def load_from_api(self, username): + def fetch_from_api(self, username): """Given a username on the other platform, return an AccountElsewhere object. - - The first thing we do is hit the API of the other platform, then we use - that to upsert our own elsewhere table, before handing back off to - load_from_db. - """ + user_id, user_info = self._fetch_from_api(username) + return self.upsert(user_id, user_info) - # Hit the platform's API to get user info. - # ======================================== + def _fetch_from_api(self, username): + # Factored out so we can call upsert without hitting API for testing. user_info = self.hit_api(username) user_id = unicode(user_info[self.user_id_key]) # If this is KeyError, then what? + return user_id, user_info + def upsert(self, user_id, user_info): + """Given a string and a dict, dance with our db and return an AccountElsewhere. + """ + # Insert the account if needed. # ============================= # Do this with a transaction so that if the insert fails, the @@ -162,10 +170,10 @@ def load_from_api(self, username): """, (user_info, self.name, user_id, self.username_key)) - # Now delegate to load_from_db. - # ============================= + # Now delegate to fetch_from_db. + # ============================== - return self.load_from_db(username) + return self.fetch_from_db(username) def resolve(self, username): diff --git a/gittip/testing/__init__.py b/gittip/testing/__init__.py index bdf925c2f6..485b963925 100644 --- a/gittip/testing/__init__.py +++ b/gittip/testing/__init__.py @@ -13,6 +13,7 @@ from aspen import resources from aspen.testing import Website, StubRequest from aspen.utils import utcnow +from gittip import wireup from gittip.billing.payday import Payday from gittip.models.participant import Participant from gittip.security.user import User @@ -74,6 +75,7 @@ class Harness(unittest.TestCase): @classmethod def setUpClass(cls): cls.db = gittip.db + wireup.platforms(cls) cls._tablenames = cls.db.all("SELECT tablename FROM pg_tables " "WHERE schemaname='public'") cls.clear_tables(cls.db, cls._tablenames[:]) @@ -91,6 +93,10 @@ def clear_tables(db, tablenames): except (IntegrityError, InternalError): tablenames.insert(0, tablename) + def make_elsewhere(self, platform, user_id, user_info): + platform = self.platforms[platform] + return platform.upsert(user_id, user_info) + def make_participant(self, username, **kw): participant = Participant.with_random_username() participant.change_username(username) diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index 3303863e2c..51960567e6 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -2,7 +2,6 @@ import mock from aspen.http.request import UnicodeWithParams -from gittip import wireup from gittip.elsewhere import twitter from gittip.testing import Harness @@ -10,11 +9,6 @@ class TestElsewhereTwitter(Harness): - - def setUp(self): - wireup.platforms(self) - - def test_twitter_resolve_resolves(self): alice_on_twitter = twitter.TwitterAccount( "1" , {'screen_name': 'alice'} diff --git a/tests/test_participant.py b/tests/test_participant.py index 00d6733a42..06f69703a8 100644 --- a/tests/test_participant.py +++ b/tests/test_participant.py @@ -70,8 +70,8 @@ def setUp(self): , claimed_time=hour_ago , last_bill_result='' ) - deadbeef = TwitterAccount('1', {'screen_name': 'deadbeef'}) - self.deadbeef_original_username = deadbeef.participant + deadbeef = self.make_elsewhere('twitter', '1', {'screen_name': 'deadbeef'}) + self.deadbeef_original_username = deadbeef.participant.username Participant.from_username('carl').set_tip_to('bob', '1.00') Participant.from_username('alice').set_tip_to(self.deadbeef_original_username, '1.00') @@ -122,7 +122,7 @@ def setUp(self): now = utcnow() for idx, username in enumerate(['alice', 'bob', 'carl'], start=1): self.make_participant(username, claimed_time=now) - twitter_account = TwitterAccount(idx, {'screen_name': username}) + twitter_account = self.make_elsewhere('twitter', str(idx), {'screen_name': username}) Participant.from_username(username).take_over(twitter_account) def test_bob_is_singular(self): @@ -521,7 +521,7 @@ def test_ru_returns_None_for_orphaned_participant(self): assert resolved is None, resolved def test_ru_returns_bitbucket_url_for_stub_from_bitbucket(self): - unclaimed = BitbucketAccount('1234', {'username': 'alice'}) + unclaimed = self.make_elsewhere('bitbucket', '1234', {'username': 'alice'}) stub = Participant.from_username(unclaimed.participant) actual = stub.resolve_unclaimed() assert actual == "/on/bitbucket/alice/" diff --git a/www/on/%platform/%username/index.html.spt b/www/on/%platform/%username/index.html.spt index c1943cfc91..67d850df3b 100644 --- a/www/on/%platform/%username/index.html.spt +++ b/www/on/%platform/%username/index.html.spt @@ -13,7 +13,7 @@ platform = website.platforms.get(path['platform']) if platform is None: raise Response(404) -account = platform.load(path['username']) +account = platform.get_account(path['username']) if account is None: raise Response(404) From c45122e6c362cf1904c73ba9f4e63933bd545944 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sat, 5 Oct 2013 21:28:12 -0400 Subject: [PATCH 032/101] test_participant passing This involved implementing AccountElsewhere and Platform classes for GitHub and Bitbucket! Woo-hoo! :D --- gittip/elsewhere/__init__.py | 13 +-- gittip/elsewhere/bitbucket.py | 74 ++++++++++------- gittip/elsewhere/github.py | 152 +++++++++++++++++++--------------- gittip/wireup.py | 4 +- tests/test_participant.py | 11 ++- 5 files changed, 142 insertions(+), 112 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index c9e45eb178..34fb099e9a 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -25,8 +25,8 @@ def __str__(self): class MissingAttributes(Exception): def __str__(self): - return "The Platform subclass {} is missing one or more attributes: {}."\ - .format(self.args[0], ','.join(self.args[1])) + return "The Platform subclass {} is missing: {}."\ + .format(self.args[0], ', '.join(self.args[1])) # Platform Objects @@ -48,10 +48,11 @@ def __getitem__(self, name): raise KeyError(name) return platform - def register(self, Platform): - platform = Platform(self.db) - self.__dict__[platform.name] = platform - AccountElsewhere.subclasses[platform.name] = platform.account_elsewhere_subclass + def register(self, *Platforms): + for Platform in Platforms: + platform = Platform(self.db) + self.__dict__[platform.name] = platform + AccountElsewhere.subclasses[platform.name] = platform.account_elsewhere_subclass class Platform(object): diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index aa824ee7ce..a4944ab9af 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -1,56 +1,66 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging -import gittip import requests from aspen import json, log, Response from aspen.http.request import UnicodeWithParams from aspen.utils import typecheck -from gittip.elsewhere import AccountElsewhere +from gittip.elsewhere import AccountElsewhere, Platform BASE_API_URL = "https://bitbucket.org/api/1.0" class BitbucketAccount(AccountElsewhere): - platform = u'bitbucket' - def get_url(self): - url = "https://bitbucket.org/%s" % self.user_info["username"] - return url + @property + def display_name(self): + return self.user_info['username'] + + @property + def img_src(self): + src = '' + # XXX Um ... ? + return src + + @property + def html_url(self): + return "https://bitbucket.org/{username}".format(**self.user_info) + + +class Bitbucket(Platform): + + name = 'bitbucket' + account_elsewhere_subclass = BitbucketAccount + username_key = 'username' + user_id_key = 'username' # No immutable id. :-/ -def oauth_url(website, action, then=""): - """Return a URL to start oauth dancing with Bitbucket. + def oauth_url(self, action, then=""): + """Return a URL to start oauth dancing with Bitbucket. - For GitHub we can pass action and then through a querystring. For Bitbucket - we can't, so we send people through a local URL first where we stash this - info in an in-memory cache (eep! needs refactoring to scale). + For GitHub we can pass action and then through a querystring. For Bitbucket + we can't, so we send people through a local URL first where we stash this + info in an in-memory cache (eep! needs refactoring to scale). - Not sure why website is here. Vestige from GitHub forebear? + Not sure why website is here. Vestige from GitHub forebear? - """ - then = then.encode('base64').strip() - return "/on/bitbucket/redirect?action=%s&then=%s" % (action, then) + """ + then = then.encode('base64').strip() + return "/on/bitbucket/redirect?action=%s&then=%s" % (action, then) -def get_user_info(username): - """Get the given user's information from the DB or failing that, bitbucket. + def hit_api(self, username): + """Get the given user's information from the DB or failing that, bitbucket. - :param username: - A unicode string representing a username in bitbucket. + :param username: + A unicode string representing a username in bitbucket. - :returns: - A dictionary containing bitbucket specific information for the user. - """ - typecheck(username, (unicode, UnicodeWithParams)) - rec = gittip.db.one( "SELECT user_info FROM elsewhere " - "WHERE platform='bitbucket' " - "AND user_info->'username' = %s" - , (username,) - ) - if rec is not None: - user_info = rec - else: + :returns: + A dictionary containing bitbucket specific information for the user. + """ + typecheck(username, (unicode, UnicodeWithParams)) url = "%s/users/%s?pagelen=100" user_info = requests.get(url % (BASE_API_URL, username)) status = user_info.status_code @@ -65,4 +75,4 @@ def get_user_info(username): level=logging.WARNING) raise Response(502, "Bitbucket lookup failed with %d." % status) - return user_info + return user_info diff --git a/gittip/elsewhere/github.py b/gittip/elsewhere/github.py index aaf27709ad..d697a1bf92 100644 --- a/gittip/elsewhere/github.py +++ b/gittip/elsewhere/github.py @@ -1,104 +1,122 @@ -from __future__ import division -import gittip +from __future__ import absolute_import, division, print_function, unicode_literals + import logging -import requests import os + +import requests from aspen import json, Response from aspen.http.request import UnicodeWithParams from aspen.utils import typecheck from aspen.website import Website from gittip import log -from gittip.elsewhere import ACTIONS, AccountElsewhere +from gittip.elsewhere import ACTIONS, AccountElsewhere, Platform class GitHubAccount(AccountElsewhere): - platform = u'github' - def get_url(self): + @property + def display_name(self): + return self.user_info['login'] + + @property + def img_src(self): + src = '' + + # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ + if 'gravatar_id' in self.user_info: + gravatar_hash = self.user_info['gravatar_id'] + src = "https://www.gravatar.com/avatar/%s.jpg?s=%s" + src %= (gravatar_hash, 128) + + return src + + @property + def html_url(self): return self.user_info['html_url'] -def oauth_url(website, action, then=u""): - """Given a website object and a string, return a URL string. - `action' is one of 'opt-in', 'lock' and 'unlock' +class GitHub(Platform): + + name = 'github' + account_elsewhere_subclass = GitHubAccount + username_key = 'login' + user_id_key = 'id' + + + def oauth_url(website, action, then=u""): + """Given a website object and a string, return a URL string. + + `action' is one of 'opt-in', 'lock' and 'unlock' - `then' is either a github username or an URL starting with '/'. It's - where we'll send the user after we get the redirect back from - GitHub. + `then' is either a github username or an URL starting with '/'. It's + where we'll send the user after we get the redirect back from + GitHub. - """ - typecheck(website, Website, action, unicode, then, unicode) - assert action in ACTIONS - url = u"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s" - url %= (website.github_client_id, website.github_callback) + """ + typecheck(website, Website, action, unicode, then, unicode) + assert action in ACTIONS + url = u"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s" + url %= (website.github_client_id, website.github_callback) - # Pack action,then into data and base64-encode. Querystring isn't - # available because it's consumed by the initial GitHub request. + # Pack action,then into data and base64-encode. Querystring isn't + # available because it's consumed by the initial GitHub request. - data = u'%s,%s' % (action, then) - data = data.encode('UTF-8').encode('base64').strip().decode('US-ASCII') - url += u'?data=%s' % data - return url + data = u'%s,%s' % (action, then) + data = data.encode('UTF-8').encode('base64').strip().decode('US-ASCII') + url += u'?data=%s' % data + return url -def oauth_dance(website, qs): - """Given a querystring, return a dict of user_info. + def oauth_dance(website, qs): + """Given a querystring, return a dict of user_info. - The querystring should be the querystring that we get from GitHub when - we send the user to the return value of oauth_url above. + The querystring should be the querystring that we get from GitHub when + we send the user to the return value of oauth_url above. - See also: + See also: - http://developer.github.com/v3/oauth/ + http://developer.github.com/v3/oauth/ - """ + """ - log("Doing an OAuth dance with Github.") + log("Doing an OAuth dance with Github.") - data = { 'code': qs['code'].encode('US-ASCII') - , 'client_id': website.github_client_id - , 'client_secret': website.github_client_secret - } - r = requests.post("https://github.com/login/oauth/access_token", data=data) - assert r.status_code == 200, (r.status_code, r.text) + data = { 'code': qs['code'].encode('US-ASCII') + , 'client_id': website.github_client_id + , 'client_secret': website.github_client_secret + } + r = requests.post("https://github.com/login/oauth/access_token", data=data) + assert r.status_code == 200, (r.status_code, r.text) - back = dict([pair.split('=') for pair in r.text.split('&')]) # XXX - if 'error' in back: - raise Response(400, back['error'].encode('utf-8')) - assert back.get('token_type', '') == 'bearer', back - access_token = back['access_token'] + back = dict([pair.split('=') for pair in r.text.split('&')]) # XXX + if 'error' in back: + raise Response(400, back['error'].encode('utf-8')) + assert back.get('token_type', '') == 'bearer', back + access_token = back['access_token'] - r = requests.get( "https://api.github.com/user" - , headers={'Authorization': 'token %s' % access_token} - ) - assert r.status_code == 200, (r.status_code, r.text) - user_info = json.loads(r.text) - log("Done with OAuth dance with Github for %s (%s)." - % (user_info['login'], user_info['id'])) + r = requests.get( "https://api.github.com/user" + , headers={'Authorization': 'token %s' % access_token} + ) + assert r.status_code == 200, (r.status_code, r.text) + user_info = json.loads(r.text) + log("Done with OAuth dance with Github for %s (%s)." + % (user_info['login'], user_info['id'])) - return user_info + return user_info -def get_user_info(login): - """Get the given user's information from the DB or failing that, github. + def hit_api(self, login): + """Get the given user's information from the DB or failing that, github. - :param login: - A unicode string representing a username in github. + :param login: + A unicode string representing a username in github. - :returns: - A dictionary containing github specific information for the user. - """ - typecheck(login, (unicode, UnicodeWithParams)) - rec = gittip.db.one( "SELECT user_info FROM elsewhere " - "WHERE platform='github' " - "AND user_info->'login' = %s" - , (login,) - ) + :returns: + A dictionary containing github specific information for the user. + """ + typecheck(login, (unicode, UnicodeWithParams)) - if rec is not None: - user_info = rec - else: url = "https://api.github.com/users/%s" user_info = requests.get(url % login, params={ 'client_id': os.environ.get('GITHUB_CLIENT_ID'), @@ -140,4 +158,4 @@ def get_user_info(login): level=logging.WARNING) raise Response(502, "GitHub lookup failed with %d." % status) - return user_info + return user_info diff --git a/gittip/wireup.py b/gittip/wireup.py index 08caff49a3..4dd691f0a3 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -11,6 +11,8 @@ import stripe import gittip.utils.mixpanel from gittip.elsewhere import PlatformRegistry +from gittip.elsewhere.bitbucket import Bitbucket +from gittip.elsewhere.github import GitHub from gittip.elsewhere.twitter import Twitter from gittip.models.community import Community from gittip.models.account_elsewhere import AccountElsewhere @@ -84,7 +86,7 @@ def nmembers(website): def platforms(website): website.platforms = PlatformRegistry(website.db) - website.platforms.register(Twitter) + website.platforms.register(Bitbucket, GitHub, Twitter) def envvars(website): diff --git a/tests/test_participant.py b/tests/test_participant.py index 06f69703a8..685bf1fd35 100644 --- a/tests/test_participant.py +++ b/tests/test_participant.py @@ -8,7 +8,6 @@ import pytz from aspen.utils import utcnow from gittip import NotSane -from gittip.elsewhere.bitbucket import BitbucketAccount from gittip.elsewhere.github import GitHubAccount from gittip.elsewhere.twitter import TwitterAccount from gittip.models._mixin_elsewhere import NeedConfirmation @@ -522,18 +521,18 @@ def test_ru_returns_None_for_orphaned_participant(self): def test_ru_returns_bitbucket_url_for_stub_from_bitbucket(self): unclaimed = self.make_elsewhere('bitbucket', '1234', {'username': 'alice'}) - stub = Participant.from_username(unclaimed.participant) + stub = Participant.from_username(unclaimed.participant.username) actual = stub.resolve_unclaimed() assert actual == "/on/bitbucket/alice/" def test_ru_returns_github_url_for_stub_from_github(self): - unclaimed = GitHubAccount('1234', {'login': 'alice'}) - stub = Participant.from_username(unclaimed.participant) + unclaimed = self.make_elsewhere('github', '1234', {'login': 'alice'}) + stub = Participant.from_username(unclaimed.participant.username) actual = stub.resolve_unclaimed() assert actual == "/on/github/alice/" def test_ru_returns_twitter_url_for_stub_from_twitter(self): - unclaimed = TwitterAccount('1234', {'screen_name': 'alice'}) - stub = Participant.from_username(unclaimed.participant) + unclaimed = self.make_elsewhere('twitter', '1234', {'screen_name': 'alice'}) + stub = Participant.from_username(unclaimed.participant.username) actual = stub.resolve_unclaimed() assert actual == "/on/twitter/alice/" From a20bdd9600bb0070033350711e3868e8bfe42a81 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sun, 6 Oct 2013 07:22:31 -0400 Subject: [PATCH 033/101] Fix test_utils --- gittip/elsewhere/__init__.py | 2 ++ gittip/models/account_elsewhere.py | 6 ++++-- gittip/testing/__init__.py | 10 ++++++++-- tests/test_participant.py | 8 +++----- tests/test_utils.py | 13 +++++++------ 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 34fb099e9a..f93c6633b8 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -125,6 +125,8 @@ def _fetch_from_api(self, username): def upsert(self, user_id, user_info): """Given a string and a dict, dance with our db and return an AccountElsewhere. """ + typecheck(user_id, unicode, user_info, dict) + # Insert the account if needed. # ============================= diff --git a/gittip/models/account_elsewhere.py b/gittip/models/account_elsewhere.py index d3f38d99f4..d010065474 100644 --- a/gittip/models/account_elsewhere.py +++ b/gittip/models/account_elsewhere.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + from gittip.models.participant import ProblemChangingUsername from gittip.security.user import User from postgres.orm import Model @@ -37,10 +39,10 @@ def opt_in(self, desired_username): """Given a desired username, return a User object. """ self.set_is_locked(False) - user = User.from_username(self.participant) + user = User.from_username(self.participant.username) user.sign_in() assert not user.ANON, self.participant # sanity check - if self.is_claimed: + if self.participant.is_claimed: newly_claimed = False else: newly_claimed = True diff --git a/gittip/testing/__init__.py b/gittip/testing/__init__.py index 485b963925..9eb9d03dc1 100644 --- a/gittip/testing/__init__.py +++ b/gittip/testing/__init__.py @@ -1,6 +1,6 @@ """Helpers for testing Gittip. """ -from __future__ import print_function, unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import datetime import random @@ -93,8 +93,14 @@ def clear_tables(db, tablenames): except (IntegrityError, InternalError): tablenames.insert(0, tablename) - def make_elsewhere(self, platform, user_id, user_info): + def make_elsewhere(self, platform, user_id, user_info=None): platform = self.platforms[platform] + if user_info is None: + user_info = {} + if platform.user_id_key not in user_info: + user_info[platform.user_id_key] = user_id + if platform.username_key not in user_info: + user_info[platform.username_key] = user_id return platform.upsert(user_id, user_info) def make_participant(self, username, **kw): diff --git a/tests/test_participant.py b/tests/test_participant.py index 685bf1fd35..9216b99f07 100644 --- a/tests/test_participant.py +++ b/tests/test_participant.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals import datetime import random @@ -8,8 +8,6 @@ import pytz from aspen.utils import utcnow from gittip import NotSane -from gittip.elsewhere.github import GitHubAccount -from gittip.elsewhere.twitter import TwitterAccount from gittip.models._mixin_elsewhere import NeedConfirmation from gittip.models.participant import Participant from gittip.models.participant import ( UsernameIsEmpty @@ -119,9 +117,9 @@ class TestParticipant(Harness): def setUp(self): super(Harness, self).setUp() now = utcnow() - for idx, username in enumerate(['alice', 'bob', 'carl'], start=1): + for i, username in enumerate(['alice', 'bob', 'carl'], start=1): self.make_participant(username, claimed_time=now) - twitter_account = self.make_elsewhere('twitter', str(idx), {'screen_name': username}) + twitter_account = self.make_elsewhere('twitter', unicode(i), {'screen_name': username}) Participant.from_username(username).take_over(twitter_account) def test_bob_is_singular(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index e06a0e7f6d..f36df24574 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,27 +1,28 @@ -from __future__ import division, print_function, unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals from aspen import Response from gittip import utils from gittip.testing import Harness, load_request -from gittip.elsewhere.twitter import TwitterAccount class Tests(Harness): def test_get_participant_gets_participant(self): - expected = TwitterAccount("alice", {}).opt_in("alice")[0].participant - request = load_request(b'/alice/') + elsewhere = self.make_elsewhere("twitter", "alice") + expected = elsewhere.opt_in("alice")[0].participant + request = load_request(b'/alice/') actual = utils.get_participant(request, restrict=False) assert actual == expected def test_get_participant_canonicalizes(self): - expected, ignored = TwitterAccount("alice", {}).opt_in("alice") - request = load_request(b'/Alice/') + self.make_elsewhere("twitter", "alice").opt_in("alice") + request = load_request(b'/Alice/') with self.assertRaises(Response) as cm: utils.get_participant(request, restrict=False) actual = cm.exception.code + assert actual == 302 def test_dict_to_querystring_converts_dict_to_querystring(self): From c83c5af8d2dbb5655d3710025572f6f8b0e852b3 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sun, 6 Oct 2013 07:28:40 -0400 Subject: [PATCH 034/101] Fix test_elsewhere_twitter I changed hit_api to get_user_info, and removed a test for the old resolve method. --- gittip/elsewhere/__init__.py | 4 ++-- gittip/elsewhere/bitbucket.py | 2 +- gittip/elsewhere/github.py | 2 +- gittip/elsewhere/twitter.py | 2 +- tests/test_elsewhere_twitter.py | 36 +++++++++++---------------------- 5 files changed, 17 insertions(+), 29 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index f93c6633b8..85430e155c 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -65,7 +65,7 @@ def __init__(self, db): # ================================================ expected_attrs = ( 'account_elsewhere_subclass' - , 'hit_api' + , 'get_user_info' , 'name' , 'username_key' , 'user_id_key' @@ -117,7 +117,7 @@ def fetch_from_api(self, username): def _fetch_from_api(self, username): # Factored out so we can call upsert without hitting API for testing. - user_info = self.hit_api(username) + user_info = self.get_user_info(username) user_id = unicode(user_info[self.user_id_key]) # If this is KeyError, then what? return user_id, user_info diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index a4944ab9af..b286584083 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -51,7 +51,7 @@ def oauth_url(self, action, then=""): return "/on/bitbucket/redirect?action=%s&then=%s" % (action, then) - def hit_api(self, username): + def get_user_info(self, username): """Get the given user's information from the DB or failing that, bitbucket. :param username: diff --git a/gittip/elsewhere/github.py b/gittip/elsewhere/github.py index d697a1bf92..ae284be1dd 100644 --- a/gittip/elsewhere/github.py +++ b/gittip/elsewhere/github.py @@ -106,7 +106,7 @@ def oauth_dance(website, qs): return user_info - def hit_api(self, login): + def get_user_info(self, login): """Get the given user's information from the DB or failing that, github. :param login: diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index f9766abadb..eec7cf9b91 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -47,7 +47,7 @@ class Twitter(Platform): username_key = 'screen_name' - def hit_api(self, screen_name): + def get_user_info(self, screen_name): """ """ # Updated using Twython as a point of reference: diff --git a/tests/test_elsewhere_twitter.py b/tests/test_elsewhere_twitter.py index 51960567e6..ffa189b299 100644 --- a/tests/test_elsewhere_twitter.py +++ b/tests/test_elsewhere_twitter.py @@ -2,41 +2,29 @@ import mock from aspen.http.request import UnicodeWithParams -from gittip.elsewhere import twitter from gittip.testing import Harness class TestElsewhereTwitter(Harness): - def test_twitter_resolve_resolves(self): - alice_on_twitter = twitter.TwitterAccount( "1" - , {'screen_name': 'alice'} - ) - alice_on_twitter.opt_in('alice') + @mock.patch('gittip.elsewhere.twitter.Twitter.get_user_info') + def test_can_load_account_elsewhere_from_twitter(self, get_user_info): + get_user_info.return_value = {"id": "123", "screen_name": "alice"} - expected = 'alice' - actual = twitter.resolve('alice') - assert actual == expected - - - @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') - def test_can_load_account_elsewhere_from_twitter(self, hit_api): - hit_api.return_value = {"id": "123", "screen_name": "alice"} - - alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) + alice_on_twitter = self.platforms.twitter.get_account(UnicodeWithParams('alice', {})) assert alice_on_twitter.user_id == "123" - @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') - def test_account_elsewhere_has_participant_object_on_it(self, hit_api): - hit_api.return_value = {"id": "123", "screen_name": "alice"} - alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) + @mock.patch('gittip.elsewhere.twitter.Twitter.get_user_info') + def test_account_elsewhere_has_participant_object_on_it(self, get_user_info): + get_user_info.return_value = {"id": "123", "screen_name": "alice"} + alice_on_twitter = self.platforms.twitter.get_account(UnicodeWithParams('alice', {})) assert not alice_on_twitter.participant.is_claimed - @mock.patch('gittip.elsewhere.twitter.Twitter.hit_api') - def test_account_elsewhere_is_twitter_account_elsewhere(self, hit_api): - hit_api.return_value = {"id": "123", "screen_name": "alice"} - alice_on_twitter = self.elsewhere.twitter.load(UnicodeWithParams('alice', {})) + @mock.patch('gittip.elsewhere.twitter.Twitter.get_user_info') + def test_account_elsewhere_is_twitter_account_elsewhere(self, get_user_info): + get_user_info.return_value = {"id": "123", "screen_name": "alice"} + alice_on_twitter = self.platforms.twitter.get_account(UnicodeWithParams('alice', {})) assert alice_on_twitter.__class__.__name__ == 'TwitterAccount' From f567c3963cf67aac8d5ec25ab060bc4ca91a5baf Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sun, 6 Oct 2013 07:32:28 -0400 Subject: [PATCH 035/101] Bring fetch_* into line with get_account --- gittip/elsewhere/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 85430e155c..77162aebe3 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -86,13 +86,13 @@ def get_account(self, username): """ typecheck(username, UnicodeWithParams) try: - out = self.fetch_from_db(username) + out = self.get_account_from_db(username) except UnknownAccountElsewhere: - out = self.fetch_from_api(username) + out = self.get_account_from_api(username) return out - def fetch_from_db(self, username): + def get_account_from_db(self, username): """Given a username on the other platform, return an AccountElsewhere object. If the account elsewhere is unknown to us, we raise UnknownAccountElsewhere. @@ -108,14 +108,17 @@ def fetch_from_db(self, username): """, (self.name, self.username_key, username), default=UnknownAccountElsewhere) - def fetch_from_api(self, username): + def get_account_from_api(self, username): """Given a username on the other platform, return an AccountElsewhere object. + + This method always hits the API and updates our database. + """ - user_id, user_info = self._fetch_from_api(username) + user_id, user_info = self._hit_api(username) return self.upsert(user_id, user_info) - def _fetch_from_api(self, username): + def _get_account_from_api(self, username): # Factored out so we can call upsert without hitting API for testing. user_info = self.get_user_info(username) user_id = unicode(user_info[self.user_id_key]) # If this is KeyError, then what? @@ -123,7 +126,7 @@ def _fetch_from_api(self, username): def upsert(self, user_id, user_info): - """Given a string and a dict, dance with our db and return an AccountElsewhere. + """Given a unicode and a dict, dance with our db and return an AccountElsewhere. """ typecheck(user_id, unicode, user_info, dict) From bdec63b2e5d9e5d70997adfe5f0ccd01b488d1f3 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sun, 6 Oct 2013 07:41:41 -0400 Subject: [PATCH 036/101] Fix naming regressions in elsewhere --- gittip/elsewhere/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 77162aebe3..b2a1b3662f 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -114,7 +114,7 @@ def get_account_from_api(self, username): This method always hits the API and updates our database. """ - user_id, user_info = self._hit_api(username) + user_id, user_info = self._get_account_from_api(username) return self.upsert(user_id, user_info) @@ -176,10 +176,10 @@ def upsert(self, user_id, user_info): """, (user_info, self.name, user_id, self.username_key)) - # Now delegate to fetch_from_db. - # ============================== + # Now delegate to get_account_from_db. + # ==================================== - return self.fetch_from_db(username) + return self.get_account_from_db(username) def resolve(self, username): From 9b1f5c191c4d391b1fc2139fdbdcc9bd3dd11039 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sun, 6 Oct 2013 07:41:51 -0400 Subject: [PATCH 037/101] Fix test_anonymous_json --- gittip/testing/__init__.py | 5 +++++ tests/test_anonymous_json.py | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/gittip/testing/__init__.py b/gittip/testing/__init__.py index 9eb9d03dc1..0191ed59cd 100644 --- a/gittip/testing/__init__.py +++ b/gittip/testing/__init__.py @@ -118,6 +118,11 @@ def make_participant(self, username, **kw): return participant + def make_user(self, username, platform='twitter', user_info=None): + elsewhere = self.make_elsewhere(platform, username) + user, newly_claimed = elsewhere.opt_in(username) + return user + def make_payday(self, *transfers): with self.db.get_cursor() as cursor: diff --git a/tests/test_anonymous_json.py b/tests/test_anonymous_json.py index 358df6bb74..823266cf95 100644 --- a/tests/test_anonymous_json.py +++ b/tests/test_anonymous_json.py @@ -2,7 +2,6 @@ from aspen import json -from gittip.elsewhere.twitter import TwitterAccount from gittip.testing import Harness from gittip.testing.client import TestClient @@ -10,7 +9,7 @@ class Tests(Harness): def hit_anonymous(self, method='GET', expected_code=200): - user, ignored = TwitterAccount('alice', {}).opt_in('alice') + self.make_user('alice') client = TestClient() response = client.get('/') From 0785af7f3983db015bf0c3d25d1adafb69955a73 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Sun, 6 Oct 2013 07:46:54 -0400 Subject: [PATCH 038/101] Toe the waters on test_associate This drops us right into replumbing oauth --- tests/test_associate.py | 4 +--- www/on/%platform/associate.spt | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_associate.py b/tests/test_associate.py index 015fe41571..f26a7406c2 100644 --- a/tests/test_associate.py +++ b/tests/test_associate.py @@ -3,8 +3,6 @@ import mock from gittip.testing import Harness, test_website as _test_website from gittip.testing.client import TestClient -from gittip.elsewhere.bitbucket import BitbucketAccount -from gittip.elsewhere.twitter import TwitterAccount class Tests(Harness): @@ -64,7 +62,7 @@ def test_associate_connects(self, track, get, post): @mock.patch('requests.get') @mock.patch('gittip.utils.mixpanel.track') def test_associate_confirms_on_connect(self, track, get, post): - TwitterAccount('1234', {'screen_name': 'alice'}).opt_in('alice') + self.make_user('alice', user_info={'id': 1234}) self.make_participant('bob') self.website.oauth_cache = {"deadbeef": ("deadbeef", "connect", "")} diff --git a/www/on/%platform/associate.spt b/www/on/%platform/associate.spt index cfd4dd81a1..ef7de5d261 100644 --- a/www/on/%platform/associate.spt +++ b/www/on/%platform/associate.spt @@ -2,13 +2,11 @@ from aspen import Response [-----------------------------] -platform = website.elsewhere.get(path['platform']) +platform = website.platforms.get(path['platform']) if platform is None: raise Response(404) -](website, username) - -service.handle_oauth_callback(qs) +platform.handle_oauth_callback(qs) [-----------------------------] text/plain From 65be9f29535386007119d23e11f3f3f7c23706ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Sat, 19 Oct 2013 23:07:15 +0200 Subject: [PATCH 039/101] Fix test_twitter_proxy. --- tests/test_pages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pages.py b/tests/test_pages.py index 71b79e47ff..18313143ef 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -94,7 +94,7 @@ def test_github_proxy(self, requests): # This hits the network. XXX add a knob to skip this def test_twitter_proxy(self): - expected = "Twitter has not joined" + expected = "twitter has not joined" actual = self.get('/on/twitter/twitter/').decode('utf8') assert expected in actual From f8881e12441619feab0566219b5bad6d3c8312c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Sun, 20 Oct 2013 21:57:52 +0200 Subject: [PATCH 040/101] Fix login links. --- gittip/elsewhere/github.py | 2 +- templates/sign-in-using.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gittip/elsewhere/github.py b/gittip/elsewhere/github.py index ae284be1dd..0f2fb2b5d6 100644 --- a/gittip/elsewhere/github.py +++ b/gittip/elsewhere/github.py @@ -44,7 +44,7 @@ class GitHub(Platform): user_id_key = 'id' - def oauth_url(website, action, then=u""): + def oauth_url(self, website, action, then=u""): """Given a website object and a string, return a URL string. `action' is one of 'opt-in', 'lock' and 'unlock' diff --git a/templates/sign-in-using.html b/templates/sign-in-using.html index 8052823f71..21f29e574a 100644 --- a/templates/sign-in-using.html +++ b/templates/sign-in-using.html @@ -3,17 +3,17 @@ Sign in
- + -

{{ escape(account.display_name) }} has

-
{{ nbackers }}
-
{{ 'person' if nbackers == 1 else 'people' }} ready to give
+

{{ escape(account.display_name) }} has

+
{{ account.nbackers }}
+
{{ 'person' if account.nbackers == 1 else 'people' }} ready to give

{{ escape(account.display_name) }} has

-
{{ account.nbackers }}
-
{{ 'person' if account.nbackers == 1 else 'people' }} ready to give
+
{{ nbackers }}
+
{{ 'person' if nbackers == 1 else 'people' }} ready to give
- - - - - -
- - -

{{ escape(username) }} has

-
{{ number }}
- {% if usertype == "user" %} -
{{ 'person' if number == 1 else 'people' }} - ready to give
- {% elif usertype == "organization" %} -
public - member{{ '' if number == 1 else 's' }}
- {% end %} -
- - {% if usertype == "user" %} - {% include "templates/participant.tip.html" %} - {% elif usertype == "organization" %} - {% if user.ANON %} - - {% end %} - {% end %} - -{% end %} - -{% block page %} - - {% if usertype == "user" %} -
- {% if account.is_locked %} - -

{{ escape(username) }} has opted out of Gittip.

- -

If you are {{ escape(username) }} - on GitHub, you can unlock your account to allow people to pledge tips - to you on Gittip.

- - - - {% else %} - - -

{{ escape(name) }} has not joined Gittip.

- -

Is this you? - {% if user.ANON %} - Click - here to opt in to Gittip. We never collect money for you until - you do. - {% else %} - Sign out and sign back in - to claim this account - {% end %} -

- - {% if user.ANON %} -

What is Gittip?

- -

Gittip is a way to thank and support your favorite artists, - musicians, writers, programmers, etc. by setting up a small weekly cash - gift to them. Read more ...

- - -

Don't like what you see?

- -

If you are {{ escape(username) }} you can explicitly opt out of - Gittip by locking this account. We don't allow new pledges to locked - accounts.

- - - {% end %} - - {% end %} -
- {% elif usertype == "organization" %} - - - - {% for i, sequence in enumerate([on_gittip, not_on_gittip]) %} - {% set nsequence = len(sequence) %} - {% if sequence %} - {% end %} - {% for member, tippee, my_tip in sequence %} - - - {% else %} - - {% end %} - - {% end %} - {% end %} -
-

{{ nsequence }} - {% if number > 0 %} - ({{ "%.1f" % (nsequence * 100 / float(number)) }}%) - {% end %} - {{ 'is' if nsequence == 1 else 'are' }} - {{ i == 0 and "also on" or "not on" }} Gittip

-
- {% if not user.ANON %} - - - {{ member['login'] }} - {% include "templates/my-tip-bulk.html" %} - - {{ member['login'] }} -
- - {% else %} - -

Not sure what to do with {{ name }}.

- - I don't recognize the “{{ usertype }}” type of user on GitHub.
- Sorry. :-( - - {% end %} -{% end %} diff --git a/www/on/github/%login/lock-fail.html.spt b/www/on/github/%login/lock-fail.html.spt deleted file mode 100644 index 88d54d0c92..0000000000 --- a/www/on/github/%login/lock-fail.html.spt +++ /dev/null @@ -1,18 +0,0 @@ -username = path['login'] -[---] -{% extends templates/base.html %} -{% block heading %}

Failure

{% end %} -{% block box %} - -
- -

Are you really {{ username }}?

- -

Your attempt to lock or unlock this account failed because you're - logged into GitHub as someone else. Please sign out of GitHub - and try again.

- -
- -{% end %} diff --git a/www/on/github/%login/public.json.spt b/www/on/github/%login/public.json.spt deleted file mode 100644 index c9bc20eb3c..0000000000 --- a/www/on/github/%login/public.json.spt +++ /dev/null @@ -1,51 +0,0 @@ -"""GitHub user page on Gittip. -""" -import re -from urllib import urlencode - -from aspen import json, Response -from gittip.elsewhere import github - - -callback_pattern = re.compile(r'^[_A-Za-z0-9.]+$') - - -def stringify_qs(qs, prefix='?'): - # XXX Upstream to Aspen - tupled = [] - for k, vals in qs.items(): - for v in vals: - tupled.append((k, v)) - return prefix + urlencode(tupled) if tupled else "" - -[--------------------------------] - -response.body = None -try: - user_info = github.get_user_info(path['login']) -except Response, res: - if res.code == 404: - pass - else: - raise -else: - usertype = user_info.get("type", "unknown type of account").lower() - if usertype == "user": - account = github.GitHubAccount(user_info['id'], user_info) - if account.is_claimed: - next_url = '/%s/public.json' % account.participant - next_url += stringify_qs(qs) - request.redirect(next_url) - -# CORS - see https://github.com/gittip/aspen-python/issues/138 -response.headers["Access-Control-Allow-Origin"] = "*" - -# JSONP - see https://github.com/gittip/aspen-python/issues/138 -callback = qs.get('callback') -if callback is not None: - if callback_pattern.match(callback) is None: - response.code = 400 - response.body = {"error": "bad callback"} - else: - response.body = "%s(%s)" % (callback, json.dumps(response.body)) - response.headers['Content-Type'] = 'application/javascript' diff --git a/www/on/github/associate.spt b/www/on/github/associate.spt deleted file mode 100644 index fe3eed7bbf..0000000000 --- a/www/on/github/associate.spt +++ /dev/null @@ -1,86 +0,0 @@ -"""Associate a GitHub account with a Gittip account. - -First we do the OAuth dance with GitHub. Once we've authenticated the user -against GitHub, we record them in our elsewhere table. This table contains -information for GitHub users whether or not they are explicit participants in -the Gittip community. - -""" -from aspen import log, Response -from aspen import resources -from gittip.elsewhere import ACTIONS, github -from gittip.models._mixin_elsewhere import NeedConfirmation -from gittip.utils import mixpanel - -[-----------------------------] - -if 'error' in qs: - request.redirect('/') - -# Load GitHub user info. -user_info = github.oauth_dance(website, qs) - -# Determine what we're supposed to do. -data = qs['data'].decode('base64').decode('UTF-8') -action, then = data.split(',', 1) -if action not in ACTIONS: - raise Response(400) - -# Make sure we have a GitHub login. -login = user_info.get('login') -if login is None: - log(u"We got a user_info from GitHub with no login [%s, %s]" - % (action, then)) - raise Response(400) - -# Do something. -log(u"%s wants to %s" % (login, action)) - -account = github.GitHubAccount(user_info['id'], user_info) - -if action == 'opt-in': # opt in - user, newly_claimed = account.opt_in(login) - del account - if newly_claimed: - mixpanel.alias_and_track(cookie, unicode(user.participant.id)) -elif action == 'connect': # connect - if user.ANON: - raise Response(404) - try: - user.participant.take_over(account) - except NeedConfirmation, obstacles: - - # XXX Eep! Internal redirect! Really?! - request.internally_redirected_from = request.fs - request.fs = website.www_root + '/on/confirm.html.spt' - request.resource = resources.get(request) - - raise request.resource.respond(request) - else: - del account -else: # lock or unlock - if then != login: - - # The user could spoof `then' to match their login, but the most they - # can do is lock/unlock their own GitHub account in a convoluted way. - - then = u'/on/github/%s/lock-fail.html' % then - - else: - - # Associate the GitHub login with a randomly-named, unclaimed Gittip - # participant. - - assert account.participant != login, login # sanity check - - account.set_is_locked(action == 'lock') - del account - -if then == u'': - then = u'/%s/' % user.participant.username -if not then.startswith(u'/'): - # Interpret it as a GitHub login. - then = u'/on/github/%s/' % then -request.redirect(then) - -[-----------------------------] text/plain From 1fad2801b8947382f6fa058e2060e0eed0fd3225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Mon, 21 Oct 2013 11:40:32 +0200 Subject: [PATCH 047/101] Adapt OAuth1 to Bitbucket. --- gittip/elsewhere/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 92ec2406fc..77141c16f9 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -276,6 +276,7 @@ def get_oauth_init_url(self, redirect_uri, qs, website): response = requests.post( "%s/oauth/request_token" % self.api_url, + data={'oauth_callback': redirect_uri}, auth=oauth_hook, ) @@ -293,8 +294,8 @@ def get_oauth_init_url(self, redirect_uri, qs, website): # when we bounced the server? website.oauth_cache[token] = (secret, action, then) - url = "%s/oauth/authenticate?oauth_token=%s&oauth_callback=%s" - return url % (self.api_url, token, redirect_uri) + url = "%s/oauth/authenticate?oauth_token=%s" + return url % (self.api_url, token) def handle_oauth_callback(self, request, website, user): qs = request.line.uri.querystring @@ -331,7 +332,10 @@ def handle_oauth_callback(self, request, website, user): reply = parse_qs(response.text) token = reply['oauth_token'][0] secret = reply['oauth_token_secret'][0] - username = reply[self.username_key][0] + if self.username_key in reply: + username = reply[self.username_key][0] + else: + username = None user_info = self.get_user_info(username, token, secret) From 6ded2be31ba1f09c071fc56cea233c3c6cfa7649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Mon, 21 Oct 2013 12:54:48 +0200 Subject: [PATCH 048/101] Bitbucket login with OAuth1. --- default_local.env | 1 + default_tests.env | 1 + gittip/elsewhere/bitbucket.py | 38 +++- gittip/wireup.py | 1 + www/on/bitbucket/%username/index.html.spt | 250 ---------------------- www/on/bitbucket/associate.spt | 124 ----------- www/on/bitbucket/redirect.spt | 40 ---- 7 files changed, 38 insertions(+), 417 deletions(-) delete mode 100644 www/on/bitbucket/%username/index.html.spt delete mode 100644 www/on/bitbucket/associate.spt delete mode 100644 www/on/bitbucket/redirect.spt diff --git a/default_local.env b/default_local.env index 9687bd88aa..6c43e6fd94 100644 --- a/default_local.env +++ b/default_local.env @@ -13,6 +13,7 @@ BALANCED_API_SECRET=90bb3648ca0a11e1a977026ba7e239a9 GITHUB_CLIENT_ID=3785a9ac30df99feeef5 GITHUB_CLIENT_SECRET=e69825fafa163a0b0b6d2424c107a49333d46985 GITHUB_CALLBACK=http://127.0.0.1:8537/on/github/associate +BITBUCKET_API_URL=https://bitbucket.org/api/1.0 BITBUCKET_CONSUMER_KEY=b8yzpsurhsmJufUqzh BITBUCKET_CONSUMER_SECRET=WF3q2g7naRHeeGUjnxyRwPLVhMBU4dmP BITBUCKET_CALLBACK=http://127.0.0.1:8537/on/bitbucket/associate diff --git a/default_tests.env b/default_tests.env index 5c75210ff2..cb5c8f10c4 100644 --- a/default_tests.env +++ b/default_tests.env @@ -13,6 +13,7 @@ BALANCED_API_SECRET=90bb3648ca0a11e1a977026ba7e239a9 GITHUB_CLIENT_ID=3785a9ac30df99feeef5 GITHUB_CLIENT_SECRET=e69825fafa163a0b0b6d2424c107a49333d46985 GITHUB_CALLBACK=http://127.0.0.1:8537/on/github/associate +BITBUCKET_API_URL=https://bitbucket.org/api/1.0 BITBUCKET_CONSUMER_KEY=b8yzpsurhsmJufUqzh BITBUCKET_CONSUMER_SECRET=WF3q2g7naRHeeGUjnxyRwPLVhMBU4dmP BITBUCKET_CALLBACK=http://127.0.0.1:8537/on/bitbucket/associate diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index b286584083..b956ba452e 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -6,7 +6,9 @@ from aspen import json, log, Response from aspen.http.request import UnicodeWithParams from aspen.utils import typecheck -from gittip.elsewhere import AccountElsewhere, Platform +from gittip.elsewhere import AccountElsewhere, PlatformOAuth1 +from os import environ +from requests_oauthlib import OAuth1 BASE_API_URL = "https://bitbucket.org/api/1.0" @@ -29,12 +31,13 @@ def html_url(self): return "https://bitbucket.org/{username}".format(**self.user_info) -class Bitbucket(Platform): +class Bitbucket(PlatformOAuth1): name = 'bitbucket' account_elsewhere_subclass = BitbucketAccount username_key = 'username' user_id_key = 'username' # No immutable id. :-/ + api_url = environ['BITBUCKET_API_URL'] def oauth_url(self, action, then=""): @@ -51,15 +54,24 @@ def oauth_url(self, action, then=""): return "/on/bitbucket/redirect?action=%s&then=%s" % (action, then) - def get_user_info(self, username): + def get_user_info(self, username, token=None, secret=None): """Get the given user's information from the DB or failing that, bitbucket. :param username: A unicode string representing a username in bitbucket. + :param token: + OAuth1 token. + + :param secret: + OAuth1 secret. + :returns: A dictionary containing bitbucket specific information for the user. """ + if not username and token and secret: + return self.get_user_info_with_oauth(token, secret) + typecheck(username, (unicode, UnicodeWithParams)) url = "%s/users/%s?pagelen=100" user_info = requests.get(url % (BASE_API_URL, username)) @@ -76,3 +88,23 @@ def get_user_info(self, username): raise Response(502, "Bitbucket lookup failed with %d." % status) return user_info + + def get_user_info_with_oauth(self, token, secret): + oauth_hook = OAuth1( + environ['BITBUCKET_CONSUMER_KEY'], + environ['BITBUCKET_CONSUMER_SECRET'], + token, + secret, + ) + response = requests.get( + "%s/user" % self.api_url, + auth=oauth_hook, + ) + user_info = json.loads(response.text)['user'] + assert response.status_code == 200, response.status_code + + assert 'html_url' not in user_info + # Add user page url. + user_info['html_url'] = "https://bitbucket.org/" + user_info[self.username_key] + + return user_info diff --git a/gittip/wireup.py b/gittip/wireup.py index 472dcf4181..87574911ec 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -102,6 +102,7 @@ def envvar(key): def is_yesish(val): return val.lower() in ('1', 'true', 'yes') + website.bitbucket_api_url = envvar('BITBUCKET_API_URL') website.bitbucket_consumer_key = envvar('BITBUCKET_CONSUMER_KEY') website.bitbucket_consumer_secret = envvar('BITBUCKET_CONSUMER_SECRET') website.bitbucket_callback = envvar('BITBUCKET_CALLBACK') diff --git a/www/on/bitbucket/%username/index.html.spt b/www/on/bitbucket/%username/index.html.spt deleted file mode 100644 index ae09080fce..0000000000 --- a/www/on/bitbucket/%username/index.html.spt +++ /dev/null @@ -1,250 +0,0 @@ -"""Bitbucket user page on Gittip. -""" -import decimal -import os -import re - -import requests -from aspen import json, Response -from gittip import CARDINALS, db -from gittip.elsewhere import bitbucket -from gittip.models.participant import Participant - - -[-----------------------------------------------------------------------------] - -# Try to load from Bitbucket. -# =========================== - -user_info = bitbucket.get_user_info(path['username']) - -# Try to load from Gittip. -# ======================== -# We can only tip Users, not Organizations (or whatever else type can be). - -username = user_info['username'] -name = user_info.get('display_name') -if not name: - name = username - -# XXX Hack to work around our stringification of hstore values, which we do in -# gittip/elsewhere/__init__.py. :-/ -is_team = user_info.get("is_team", None) in (True, u"True") - -usertype = "unknown type of account" -if is_team is None: - can_tip = False - img_src = "/assets/-/avatar-default.gif" -elif not is_team: - usertype = "user" - account = bitbucket.BitbucketAccount(unicode(username), user_info) - locked = account.is_locked - lock_action = "unlock" if account.is_locked else "lock" - if account.is_claimed: - request.redirect('/%s/' % account.participant) - - participant = Participant.from_username(account.participant) - - if not user.ANON: - my_tip = user.participant.get_tip_to(participant.username) - - tip_or_pledge = "pledge" - number = participant.get_number_of_backers() - img_src = participant.get_img_src(128) - -elif is_team: - usertype = "organization" - - bb_url = "https://bitbucket.org/api/2.0/users/%s/members" % username - members = [] - resp = requests.get(bb_url) - if resp.status_code is not 200: - raise Response(404, "Bitbucket is currently unavailable") - else: - bb_data = json.loads(resp.text) - for m in bb_data['values']: - members.append(m) - - while 'next' in bb_data: - next_url = u"https://bitbucket.org%s" % bb_data['next'] - resp = requests.get(next_url) - bb_data = json.loads(resp.text) - for m in bb_data['values']: - members.append(m) - - usernames = [member['username'] for member in members] - - ON_GITTIP = """\ - - SELECT participant, elsewhere.user_info -> 'username' AS username - FROM elsewhere - JOIN participants p - ON p.username = elsewhere.participant - WHERE elsewhere.platform = 'bitbucket' - AND elsewhere.user_info -> 'username' = any(%s) - AND p.claimed_time IS NOT NULL - - """ - known = db.all(ON_GITTIP, (usernames,)) - known_map = {rec.username: rec.participant for rec in known} - _on_gittip = [rec.username for rec in known] - on_gittip = [] - not_on_gittip = [] - for member in members: - if member['username'] in _on_gittip: - seq = on_gittip - tippee = known_map[member['username']] - else: - seq = not_on_gittip - elsewhere = bitbucket.BitbucketAccount(member['username'], member) - tippee = elsewhere.participant - tip_to = None if user.ANON else user.participant.get_tip_to(tippee) - seq.append((member, tippee, tip_to)) - can_tip = user.ANON - number = len(members) - img_src = user_info['avatar'] + "&s=128" - - -url = "https://bitbucket.org/%s" % username -title = username -[-----------------------------------------------------------------------------] -{% extends templates/base.html %} - -{% block heading %}

Bitbucket

{% end %} - -{% block box %} - - - - - - -
- - -

{{ escape(username) }} has

-
{{ number }}
- {% if usertype == "user" %} -
{{ 'person' if number == 1 else 'people' }} ready to give
- {% elif usertype == "organization" %} -
public member{{ '' if number == 1 else 's' }}
- {% end %} -
- - {% if usertype == "user" %} - {% include "templates/participant.tip.html" %} - {% elif usertype == "organization" %} - {% if user.ANON %} - - {% end %} - {% end %} -{% end %} - -{% block page %} - - {% if usertype == "user" %} -
- {% if account.is_locked %} - -

{{ escape(username) }} has opted out of Gittip.

- -

If you are {{ escape(username) }} - on Bitbucket, you can unlock your account to allow people to pledge - tips to you on Gittip.

- - {% if 0 %} - - {% end %} - - {% else %} - - -

{{ escape(name) }} has not joined Gittip.

- -

Is this you? - {% if user.ANON %} - - {% if 0 %} - Click - here to opt in to Gittip. We never collect money for you until - you do. - {% end %} - - - {% else %} - Sign out and sign back in - to claim this account - {% end %} -

- - {% if user.ANON %} -

What is Gittip?

- -

Gittip is a way to thank and support your favorite artists, - musicians, writers, programmers, etc. by setting up a small weekly cash - gift to them. Read more ...

- - -

Don't like what you see?

- -

If you are {{ escape(username) }} you can explicitly opt out of - Gittip by locking this account. We don't allow new pledges to locked - accounts.

- - {% if 0 %} - - {% end %} - {% end %} - - {% end %} -
- {% elif usertype == "organization" %} - - - - - {% for i, sequence in enumerate([on_gittip, not_on_gittip]) %} - {% set nsequence = len(sequence) %} - {% if sequence %} - {% end %} - {% for member, tippee, my_tip in sequence %} - - - {% else %} - - {% end %} - - {% end %} - {% end %} -
-

{{ nsequence }} - {% if number > 0 %} - ({{ "%.1f" % (nsequence * 100 / float(number)) }}%) - {% end %} - {{ 'is' if nsequence == 1 else 'are' }} - {{ i == 0 and "also on" or "not on" }} Gittip

-
- {% if not user.ANON %} - - - {{ member['username'] }} - - {% include "templates/my-tip-bulk.html" %} - - {{ member['username'] }} -
- - {% else %} - -

Not sure what to do with {{ name }}.

- - I don't recognize the “{{ usertype }}” type of user on Bitbucket.
- Sorry. :-( - - {% end %} -{% end %} diff --git a/www/on/bitbucket/associate.spt b/www/on/bitbucket/associate.spt deleted file mode 100644 index fc6efb7c7e..0000000000 --- a/www/on/bitbucket/associate.spt +++ /dev/null @@ -1,124 +0,0 @@ -"""Associate a Bitbucket account with a Gittip account. - -First we do the OAuth dance with Bitbucket. Once we've authenticated the user -against Bitbucket, we record them in our elsewhere table. This table contains -information for Bitbucket users whether or not they are explicit participants in -the Gittip community. - -""" -from urlparse import parse_qs - -import requests -from requests_oauthlib import OAuth1 -from aspen import json, log, Response -from aspen import resources -from gittip.elsewhere import ACTIONS, bitbucket -from gittip.models._mixin_elsewhere import NeedConfirmation -from gittip.utils import mixpanel -\ -[-----------------------------] - -if 'denied' in qs: - request.redirect('/') - - -token = qs['oauth_token'] -try: - secret, action, then = website.oauth_cache.pop(token) - then = then.decode('base64') -except KeyError: - request.redirect("/about/me.html") - -oauth_hook = OAuth1( website.bitbucket_consumer_key - , website.bitbucket_consumer_secret - , token - , secret - ) -response = requests.post( "https://bitbucket.org/api/1.0/oauth/access_token" - , data={"oauth_verifier": qs['oauth_verifier']} - , auth=oauth_hook - ) -assert response.status_code == 200, response.status_code - -reply = parse_qs(response.text) -token = reply['oauth_token'][0] -secret = reply['oauth_token_secret'][0] - -oauth_hook = OAuth1( website.bitbucket_consumer_key - , website.bitbucket_consumer_secret - , token - , secret - ) -response = requests.get( "https://bitbucket.org/api/1.0/user" - , auth=oauth_hook - ) -user_info = json.loads(response.text)['user'] -assert response.status_code == 200, response.status_code - - -# Load Bitbucket user info. - -if action not in ACTIONS: - raise Response(400) - -# Make sure we have a Bitbucket username. -username = user_info.get('username') -if username is None: - log(u"We got a user_info from Bitbucket with no username [%s, %s]" - % (action, then)) - raise Response(400) -assert 'html_url' not in user_info -user_info['html_url'] = "https://bitbucket.org/" + username - -# Do something. -log(u"%s wants to %s" % (username, action)) - -account = bitbucket.BitbucketAccount(username, user_info) - -if action == 'opt-in': # opt in - # set 'user' to give them a session :/ - user, newly_claimed = account.opt_in(username) - del account - if newly_claimed: - mixpanel.alias_and_track(cookie, unicode(user.participant.id)) -elif action == 'connect': # connect - if user.ANON: - raise Response(404) - try: - user.participant.take_over(account) - except NeedConfirmation, obstacles: - - # XXX Eep! Internal redirect! Really?! - request.internally_redirected_from = request.fs - request.fs = website.www_root + '/on/confirm.html.spt' - request.resource = resources.get(request) - - raise request.resource.respond(request) - else: - del account -else: # lock or unlock - if then != username: - - # The user could spoof `then' to match their username, but the most - # they can do is lock/unlock their own Bitbucket account in a convoluted - # way. - - then = u'/on/bitbucket/%s/lock-fail.html' % then - - else: - - # Associate the Bitbucket username with a randomly-named, unclaimed - # Gittip participant. - - assert account.participant != username, username # sanity check - account.set_is_locked(action == 'lock') - del account - -if then == u'': - then = u'/%s/' % user.participant.username -if not then.startswith(u'/'): - # Interpret it as a Bitbucket username. - then = u'/on/bitbucket/%s/' % then -request.redirect(then) - -[-----------------------------] text/plain diff --git a/www/on/bitbucket/redirect.spt b/www/on/bitbucket/redirect.spt deleted file mode 100644 index 6f8d0b7f53..0000000000 --- a/www/on/bitbucket/redirect.spt +++ /dev/null @@ -1,40 +0,0 @@ -"""Part of Bitbucket oauth. - -From here we redirect users to Bitbucket after storing needed info in an -in-memory cache. We get them again at www/on/bitbucket/associate. - -""" -from urlparse import parse_qs - -import requests -from requests_oauthlib import OAuth1 - -website.oauth_cache = {} # XXX What happens to someone who was half-authed - # when we bounced the server? - -[-----------------------------] - -oauth_hook = OAuth1( website.bitbucket_consumer_key - , website.bitbucket_consumer_secret - ) - -response = requests.post( "https://bitbucket.org/api/1.0/oauth/request_token" - , data={'oauth_callback': website.bitbucket_callback} - , auth=oauth_hook - ) - -assert response.status_code == 200, response.status_code # safety check - -reply = parse_qs(response.text) - -token = reply['oauth_token'][0] -secret = reply['oauth_token_secret'][0] -assert reply['oauth_callback_confirmed'][0] == "true" # sanity check - -action = qs.get('action', 'opt-in') -then = qs.get('then', '') -website.oauth_cache[token] = (secret, action, then) - -url = "https://bitbucket.org/api/1.0/oauth/authenticate?oauth_token=%s" -request.redirect(url % token) -[-----------------------------] text/plain From 9f064184376b621a92b72926997f7015bfdc2237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Mon, 21 Oct 2013 12:59:06 +0200 Subject: [PATCH 049/101] Fix links to connect accounts. --- templates/connected-accounts.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/connected-accounts.html b/templates/connected-accounts.html index 9fc0eaf704..a93ae2de63 100644 --- a/templates/connected-accounts.html +++ b/templates/connected-accounts.html @@ -8,7 +8,7 @@

Connected Accounts

{% if twitter_account is None %} {% if not user.ANON and user.participant == participant %} - Connect a Twitter account. + Connect a Twitter account. {% else %} No Twitter account connected. {% end %} @@ -32,7 +32,7 @@

Connected Accounts

{% if github_account is None %} {% if not user.ANON and user.participant == participant %} - Connect a GitHub account. + Connect a GitHub account. {% else %} No GitHub account connected. {% end %} @@ -55,7 +55,7 @@

Connected Accounts

{% if bitbucket_account is None %} {% if not user.ANON and user.participant == participant %} - Connect a Bitbucket account. + Connect a Bitbucket account. {% else %} No Bitbucket account connected. {% end %} @@ -79,7 +79,7 @@

Connected Accounts

{% if bountysource_account is None %} {% if not user.ANON and user.participant == participant %} - Connect a Bountysource account. + Connect a Bountysource account. {% else %} No Bountysource account connected. {% end %} From 51597fd723c345f47cc66c12190059071cf065a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Mon, 21 Oct 2013 13:05:30 +0200 Subject: [PATCH 050/101] Fix action to connect accounts. --- gittip/elsewhere/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 77141c16f9..e8aac0dd01 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -7,11 +7,12 @@ from gittip.models.participant import reserve_a_random_username from gittip.models.account_elsewhere import AccountElsewhere from psycopg2 import IntegrityError -from aspen import json, log, Response +from aspen import json, log, Response, resources from requests_oauthlib import OAuth1 import requests from urlparse import parse_qs from gittip.utils import mixpanel +from gittip.models._mixin_elsewhere import NeedConfirmation ACTIONS = ['opt-in', 'connect', 'lock', 'unlock'] From ffb604398f740351f4e5e78842f3fc641516f2ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Tue, 22 Oct 2013 11:03:08 +0200 Subject: [PATCH 051/101] Fix links to connect accounts, again :( --- templates/connected-accounts.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/connected-accounts.html b/templates/connected-accounts.html index a93ae2de63..273fa1ecc5 100644 --- a/templates/connected-accounts.html +++ b/templates/connected-accounts.html @@ -8,7 +8,7 @@

Connected Accounts

{% if twitter_account is None %} {% if not user.ANON and user.participant == participant %} - Connect a Twitter account. + Connect a Twitter account. {% else %} No Twitter account connected. {% end %} @@ -55,7 +55,7 @@

Connected Accounts

{% if bitbucket_account is None %} {% if not user.ANON and user.participant == participant %} - Connect a Bitbucket account. + Connect a Bitbucket account. {% else %} No Bitbucket account connected. {% end %} From dd22ea800dbbeffaace1927804d50da60ee77a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Tue, 22 Oct 2013 12:04:36 +0200 Subject: [PATCH 052/101] Fix TestPages.test_homepage_with_anonymous_giver. --- tests/test_pages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_pages.py b/tests/test_pages.py index ebb77d8215..b40d91dab7 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -9,6 +9,7 @@ from gittip.testing import GITHUB_USER_UNREGISTERED_LGTEST, Harness from gittip.testing.client import TestClient from gittip.utils import update_homepage_queries_once +from aspen.http.request import UnicodeWithParams class TestPages(Harness): @@ -26,7 +27,7 @@ def test_homepage(self): assert expected in actual def test_homepage_with_anonymous_giver(self): - TwitterAccount("bob", {}).opt_in("bob") + self.platforms.twitter.get_account(UnicodeWithParams('bob', {})).opt_in("bob") alice = self.make_participant('alice', anonymous=True, last_bill_result='') alice.set_tip_to('bob', 1) update_homepage_queries_once(self.db) From 9b529bf49b7ffe25ba4d49a8a220ee9e99e67833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Thu, 24 Oct 2013 21:29:39 +0200 Subject: [PATCH 053/101] Fix return values of OAuth posts of mocks in test_associate.py. --- tests/test_associate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_associate.py b/tests/test_associate.py index f26a7406c2..cd07515dd0 100644 --- a/tests/test_associate.py +++ b/tests/test_associate.py @@ -24,7 +24,7 @@ def test_associate_opts_in(self, track, get, post): self.website.oauth_cache = {"deadbeef": ("deadbeef", "opt-in", "")} post.return_value.status_code = 200 - post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&user_id=foo" + post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&screen_name=foo" get.return_value.status_code = 200 get.return_value.text = '{"id": 1234, "screen_name": "alice"}' @@ -43,7 +43,7 @@ def test_associate_connects(self, track, get, post): self.website.oauth_cache = {"deadbeef": ("deadbeef", "connect", "")} post.return_value.status_code = 200 - post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&user_id=foo" + post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&screen_name=foo" get.return_value.status_code = 200 get.return_value.text = '{"id": 1234, "screen_name": "alice"}' @@ -68,7 +68,7 @@ def test_associate_confirms_on_connect(self, track, get, post): self.website.oauth_cache = {"deadbeef": ("deadbeef", "connect", "")} post.return_value.status_code = 200 - post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&user_id=foo" + post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&screen_name=foo" get.return_value.status_code = 200 get.return_value.text = '{"id": 1234, "screen_name": "alice"}' @@ -90,7 +90,7 @@ def test_confirmation_properly_displays_remaining_bitbucket(self, track, get, po self.website.oauth_cache = {"deadbeef": ("deadbeef", "connect", "")} post.return_value.status_code = 200 - post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&user_id=foo" + post.return_value.text = "oauth_token=foo&oauth_token_secret=foo&screen_name=foo" get.return_value.status_code = 200 get.return_value.text = '{"id": 1234, "screen_name": "alice"}' From bd0042e39ff11ca78ac68090621b4c7071d6c139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Thu, 24 Oct 2013 23:39:41 +0200 Subject: [PATCH 054/101] Fix TestAccountElsewhere.test_bitbucket_oauth_url_percent_encodes_then. --- tests/test_elsewhere.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_elsewhere.py b/tests/test_elsewhere.py index c570bf8f8e..884b7fb29f 100644 --- a/tests/test_elsewhere.py +++ b/tests/test_elsewhere.py @@ -39,10 +39,10 @@ def test_twitter_oauth_url_percent_encodes_then(self): def test_bitbucket_oauth_url_percent_encodes_then(self): expected = '/on/bitbucket/redirect?action=opt-in&then=L29uL3R3aXR0ZXIvIj48aW1nIHNyYz14IG9uZXJyb3I9cHJvbXB0KDEpOz4v' - actual = bitbucket.oauth_url( website=None - , action='opt-in' - , then=self.xss - ) + actual = self.platforms.bitbucket.oauth_url( + action='opt-in', + then=self.xss, + ) assert actual == expected def test_github_oauth_url_not_susceptible_to_injection_attack(self): From dbc1e4c5348e1a64279018910d1e73542c016313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Thu, 24 Oct 2013 23:47:31 +0200 Subject: [PATCH 055/101] Fix TestAccountElsewhere.test_github_oauth_url_not_susceptible_to_injection_attack. --- tests/test_elsewhere.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_elsewhere.py b/tests/test_elsewhere.py index 884b7fb29f..6744811540 100644 --- a/tests/test_elsewhere.py +++ b/tests/test_elsewhere.py @@ -50,8 +50,9 @@ def test_github_oauth_url_not_susceptible_to_injection_attack(self): website = Website([]) website.github_client_id = 'cheese' website.github_callback= 'nuts' - actual = github.oauth_url( website=website - , action='opt-in' - , then=self.xss - ) + actual = self.platforms.github.oauth_url( + website=website, + action='opt-in', + then=self.xss, + ) assert actual == expected From 95562a04f8f179a5d05eb4ca86198ce282d67655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Thu, 24 Oct 2013 23:52:36 +0200 Subject: [PATCH 056/101] Fix TestAccountElsewhere.test_opt_in_can_change_username. --- tests/test_elsewhere.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_elsewhere.py b/tests/test_elsewhere.py index 6744811540..5ab91fe232 100644 --- a/tests/test_elsewhere.py +++ b/tests/test_elsewhere.py @@ -1,9 +1,8 @@ from __future__ import print_function, unicode_literals from aspen.website import Website -from gittip.elsewhere.twitter import TwitterAccount from gittip.testing import Harness -from gittip.elsewhere import bitbucket, github, twitter +from aspen.http.request import UnicodeWithParams # I ended up using TwitterAccount to test even though this is generic # functionality, because the base class is too abstract. @@ -12,7 +11,7 @@ class TestAccountElsewhere(Harness): def test_opt_in_can_change_username(self): - account = TwitterAccount("alice", {}) + account = self.platforms.twitter.get_account(UnicodeWithParams("alice", {})) expected = "bob" actual = account.opt_in("bob")[0].participant.username assert actual == expected From 9f0dda26bc146675798e0a679c12c146d436d1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Fri, 25 Oct 2013 00:00:10 +0200 Subject: [PATCH 057/101] Fix TestAccountElsewhere.test_twitter_oauth_url_percent_encodes_then. --- tests/test_elsewhere.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_elsewhere.py b/tests/test_elsewhere.py index 5ab91fe232..90ad0a410a 100644 --- a/tests/test_elsewhere.py +++ b/tests/test_elsewhere.py @@ -30,10 +30,10 @@ def test_opt_in_doesnt_have_to_change_username(self): xss = '/on/twitter/">/' def test_twitter_oauth_url_percent_encodes_then(self): expected = '/on/twitter/redirect?action=opt-in&then=L29uL3R3aXR0ZXIvIj48aW1nIHNyYz14IG9uZXJyb3I9cHJvbXB0KDEpOz4v' - actual = twitter.oauth_url( website=None - , action='opt-in' - , then=self.xss - ) + actual = self.platforms.twitter.oauth_url( + action='opt-in', + then=self.xss, + ) assert actual == expected def test_bitbucket_oauth_url_percent_encodes_then(self): From dab99334b80f5a0a730155b0d3cceeb34c5a1ff4 Mon Sep 17 00:00:00 2001 From: Changaco Date: Fri, 7 Feb 2014 21:48:36 +0100 Subject: [PATCH 058/101] WIP --- branch.sql | 179 ++++++- configure-aspen.py | 9 - default_local.env | 13 +- default_tests.env | 13 +- gittip/elsewhere/__init__.py | 500 +++++++----------- gittip/elsewhere/_extractors.py | 68 +++ gittip/elsewhere/bitbucket.py | 115 +--- gittip/elsewhere/bountysource.py | 181 +++---- gittip/elsewhere/github.py | 174 +----- gittip/elsewhere/openstreetmap.py | 83 +-- gittip/elsewhere/twitter.py | 129 +---- gittip/elsewhere/venmo.py | 72 +-- gittip/exceptions.py | 3 - gittip/models/_mixin_elsewhere.py | 424 --------------- gittip/models/account_elsewhere.py | 46 +- gittip/models/participant.py | 388 +++++++++++++- gittip/testing/__init__.py | 25 +- gittip/utils/__init__.py | 61 +-- gittip/utils/fake_data.py | 40 +- gittip/wireup.py | 86 +-- requirements.txt | 3 +- scss/buttons-knobs.scss | 13 + scss/lib/_dropdown.scss | 32 +- scss/widgets/_sign_in.scss | 37 +- templates/auth.html | 9 + templates/connected-accounts.html | 145 +---- templates/participant.html | 2 +- templates/sign-in-using.html | 29 +- tests/test_associate.py | 107 ---- tests/test_elsewhere.py | 64 --- tests/test_elsewhere_bitbucket.py | 20 - tests/test_elsewhere_bountysource.py | 11 - tests/test_elsewhere_github.py | 31 -- tests/test_elsewhere_openstreetmap.py | 35 -- tests/test_elsewhere_twitter.py | 35 -- tests/test_pages.py | 12 +- tests/test_participant.py | 62 +-- vendor/requests-oauthlib-0.3.2.tar.gz | Bin 11370 -> 0 bytes vendor/requests-oauthlib-0.4.0.tar.gz | Bin 0 -> 12104 bytes vendor/xmltodict-0.8.4.tar.gz | Bin 0 -> 12240 bytes www/%username/giving/index.html.spt | 15 +- www/%username/index.html.spt | 8 +- www/%username/members/index.html.spt | 12 +- www/%username/public.json.spt | 34 +- www/about/me.html.spt | 9 +- www/assets/{octocat.png => github.png} | Bin www/for/%slug/index.html.spt | 44 +- www/index.html.spt | 38 +- www/on/%platform/%user_name/failure.html.spt | 33 ++ .../{%username => %user_name}/index.html.spt | 58 +- www/on/%platform/associate.spt | 66 ++- www/on/%platform/redirect.spt | 47 +- www/on/bountysource/associate.spt | 65 --- www/on/bountysource/failure.html.spt | 17 - www/on/bountysource/redirect.spt | 32 -- www/on/confirm.html.spt | 97 ++-- www/on/openstreetmap/%username/index.html.spt | 129 ----- www/on/openstreetmap/associate.spt | 125 ----- www/on/openstreetmap/redirect.spt | 40 -- www/on/take-over.html.spt | 17 +- www/on/venmo/associate.spt | 37 -- www/sign-out.html.spt | 2 +- 62 files changed, 1463 insertions(+), 2718 deletions(-) create mode 100644 gittip/elsewhere/_extractors.py delete mode 100644 gittip/models/_mixin_elsewhere.py create mode 100644 templates/auth.html delete mode 100644 tests/test_associate.py delete mode 100644 tests/test_elsewhere.py delete mode 100644 tests/test_elsewhere_bitbucket.py delete mode 100644 tests/test_elsewhere_bountysource.py delete mode 100644 tests/test_elsewhere_github.py delete mode 100644 tests/test_elsewhere_openstreetmap.py delete mode 100644 tests/test_elsewhere_twitter.py delete mode 100644 vendor/requests-oauthlib-0.3.2.tar.gz create mode 100644 vendor/requests-oauthlib-0.4.0.tar.gz create mode 100644 vendor/xmltodict-0.8.4.tar.gz rename www/assets/{octocat.png => github.png} (100%) create mode 100644 www/on/%platform/%user_name/failure.html.spt rename www/on/%platform/{%username => %user_name}/index.html.spt (54%) delete mode 100644 www/on/bountysource/associate.spt delete mode 100644 www/on/bountysource/failure.html.spt delete mode 100644 www/on/bountysource/redirect.spt delete mode 100644 www/on/openstreetmap/%username/index.html.spt delete mode 100644 www/on/openstreetmap/associate.spt delete mode 100644 www/on/openstreetmap/redirect.spt delete mode 100644 www/on/venmo/associate.spt diff --git a/branch.sql b/branch.sql index 400ff95a6a..7176c9c7c6 100644 --- a/branch.sql +++ b/branch.sql @@ -1,44 +1,197 @@ ------------------------------------------------------------------------------- -- https://github.com/gittip/www.gittip.com/pull/1369 +BEGIN; --- The following lets us cast queries to elsewhere_with_participant to get the --- participant data dereferenced and returned in a composite type along with --- the elsewhere data. Then we can register orm.Models in the application for --- both participant and elsewhere_with_participant, and when we cast queries --- elsewhere.*::elsewhere_with_participant, we'll get a hydrated Participant --- object at .participant. Woo-hoo! + -- Extract user_name from user_info + -- Note: using "user_name" instead of "username" avoids having the same + -- column name in the participants and elsewhere tables. + ALTER TABLE elsewhere ADD COLUMN user_name text; + UPDATE elsewhere SET user_name = user_id WHERE platform = 'bitbucket'; + UPDATE elsewhere SET user_name = user_info->'display_name' WHERE platform = 'bountysource'; + UPDATE elsewhere SET user_name = user_info->'login' WHERE platform = 'github'; + UPDATE elsewhere SET user_name = user_info->'username' WHERE platform = 'openstreetmap'; + UPDATE elsewhere SET user_name = user_info->'screen_name' WHERE platform = 'twitter'; + UPDATE elsewhere SET user_name = user_info->'username' WHERE platform = 'venmo'; + -- Remove duplicates + DELETE FROM elsewhere + WHERE (platform, user_name) IN ( + SELECT platform, user_name FROM elsewhere + GROUP BY platform, user_name HAVING count(*) > 1 + ) + AND id NOT IN ( + SELECT max(id) FROM elsewhere + GROUP BY platform, user_name HAVING count(*) > 1 + ); + DELETE FROM participants WHERE username NOT IN ( + SELECT participant FROM elsewhere GROUP BY participant + ); + -- Update constraints + ALTER TABLE elsewhere ALTER COLUMN user_name SET NOT NULL, + ALTER COLUMN user_name DROP DEFAULT, + ADD UNIQUE (platform, user_name); -BEGIN; + -- Extract display_name from user_info + ALTER TABLE elsewhere ADD COLUMN display_name text; + UPDATE elsewhere SET display_name = user_info->'display_name' WHERE platform = 'bitbucket'; + UPDATE elsewhere SET display_name = user_info->'name' WHERE platform = 'github'; + UPDATE elsewhere SET display_name = user_info->'username' WHERE platform = 'openstreetmap'; + UPDATE elsewhere SET display_name = user_info->'name' WHERE platform = 'twitter'; + UPDATE elsewhere SET display_name = user_info->'display_name' WHERE platform = 'venmo'; + UPDATE elsewhere SET display_name = NULL WHERE display_name = 'None'; + + -- Add columns for email addresses + ALTER TABLE elsewhere ADD COLUMN email text; + ALTER TABLE participants ADD COLUMN email text; + -- Create a function and a trigger to automatically propagate email + -- addresses to the participants table when they are inserted of updated + -- in the elsewhere table + CREATE FUNCTION propagate_email() RETURNS trigger AS $$ + DECLARE + participant_username text; + new_email text; + BEGIN + participant_username := NEW.participant; + new_email := ( + SELECT max(email) FROM elsewhere + WHERE participant = participant_username + ); + IF (new_email IS NOT NULL) THEN + UPDATE participants p + SET email = new_email + WHERE p.username = participant_username; + END IF; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER propagate_email + AFTER INSERT OR UPDATE OF email ON elsewhere + FOR EACH ROW + EXECUTE PROCEDURE propagate_email(); + -- Extract available email addresses + UPDATE elsewhere SET email = user_info->'email' WHERE user_info->'email' LIKE '%@%'; + + + -- Add columns for avatar URLs + ALTER TABLE elsewhere ADD COLUMN avatar_url text; + ALTER TABLE participants ADD COLUMN avatar_url text; + -- Create a function and a trigger to automatically clean up avatar URLs + CREATE FUNCTION clean_avatar_url() RETURNS trigger AS $$ + BEGIN + IF (NEW.avatar_url LIKE '%gravatar.com%') THEN + NEW.avatar_url := concat(substring(NEW.avatar_url from '^([^?#]+)'), + '?s=128'); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER clean_avatar_url + BEFORE INSERT OR UPDATE OF avatar_url ON elsewhere + FOR EACH ROW + EXECUTE PROCEDURE clean_avatar_url(); + -- Create a function and a trigger to automatically propagate avatar URLs + -- to the participants table when they are inserted of updated in the + -- elsewhere table + CREATE FUNCTION propagate_avatar_url() RETURNS trigger AS $$ + DECLARE + participant_username text; + BEGIN + participant_username := NEW.participant; + UPDATE participants p + SET avatar_url = ( + SELECT avatar_url + FROM elsewhere + WHERE participant = p.username AND avatar_url != 'None' + ORDER BY platform = 'github' DESC, + avatar_url LIKE '%gravatar.com%' DESC + LIMIT 1 + ) + WHERE p.username = participant_username; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER propagate_avatar_url + AFTER INSERT OR UPDATE OF avatar_url ON elsewhere + FOR EACH ROW + EXECUTE PROCEDURE propagate_avatar_url(); + -- Extract available avatar URLs + UPDATE elsewhere SET avatar_url = concat('https://www.gravatar.com/avatar/', + user_info->'gravatar_id') + WHERE platform = 'github' + AND user_info->'gravatar_id' != '' + AND user_info->'gravatar_id' != 'None'; + UPDATE elsewhere SET avatar_url = concat('https://www.gravatar.com/avatar/', + md5(lower(trim(email)))) + WHERE email IS NOT NULL AND avatar_url IS NULL; + UPDATE elsewhere SET avatar_url = user_info->'avatar' WHERE platform = 'bitbucket'; + UPDATE elsewhere SET avatar_url = user_info->'avatar_url' + WHERE platform = 'bitbucket' AND avatar_url IS NULL; + UPDATE elsewhere SET avatar_url = substring(user_info->'links', $$u'avatar': {u'href': u'([^']+)$$) + WHERE platform = 'bitbucket' AND avatar_url IS NULL; + UPDATE elsewhere SET avatar_url = user_info->'image_url' WHERE platform = 'bountysource'; + UPDATE elsewhere SET avatar_url = user_info->'avatar_url' WHERE platform = 'github' AND avatar_url IS NULL; + UPDATE elsewhere SET avatar_url = user_info->'img_src' WHERE platform = 'openstreetmap'; + UPDATE elsewhere SET avatar_url = replace(user_info->'profile_image_url_https', '_normal.', '.') + WHERE platform = 'twitter'; + UPDATE elsewhere SET avatar_url = user_info->'profile_picture_url' WHERE platform = 'venmo'; + UPDATE elsewhere SET avatar_url = NULL WHERE avatar_url = 'None'; + + + -- Replace the column by a new one of type json (instead of hstore) + ALTER TABLE elsewhere DROP COLUMN user_info, + ADD COLUMN extra_info json; + DROP EXTENSION hstore; + + + -- Simplify homepage_top_* tables + ALTER TABLE homepage_top_givers DROP COLUMN gravatar_id, + DROP COLUMN twitter_pic, + ADD COLUMN avatar_url text; + ALTER TABLE homepage_top_receivers DROP COLUMN claimed_time, + DROP COLUMN gravatar_id, + DROP COLUMN twitter_pic, + ADD COLUMN avatar_url text; + + + -- The following lets us cast queries to elsewhere_with_participant to get the + -- participant data dereferenced and returned in a composite type along with + -- the elsewhere data. Then we can register orm.Models in the application for + -- both participant and elsewhere_with_participant, and when we cast queries + -- elsewhere.*::elsewhere_with_participant, we'll get a hydrated Participant + -- object at .participant. Woo-hoo! CREATE TYPE elsewhere_with_participant AS ( id integer , platform text , user_id text - , user_info hstore + , user_name text + , display_name text + , email text + , avatar_url text + , extra_info json , is_locked boolean , participant participants ); -- If Postgres had type inheritance this would be even awesomer. - CREATE OR REPLACE FUNCTION load_participant_for_elsewhere (elsewhere) RETURNS elsewhere_with_participant AS $$ - SELECT $1.id , $1.platform , $1.user_id - , $1.user_info + , $1.user_name + , $1.display_name + , $1.email + , $1.avatar_url + , $1.extra_info , $1.is_locked , participants.*::participants FROM participants WHERE participants.username = $1.participant ; - $$ LANGUAGE SQL; - CREATE CAST (elsewhere AS elsewhere_with_participant) WITH FUNCTION load_participant_for_elsewhere(elsewhere); diff --git a/configure-aspen.py b/configure-aspen.py index c002d201e8..a97890d8bb 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -12,7 +12,6 @@ from gittip import canonize, configure_payments from gittip.security import authentication, csrf, x_frame_options from gittip.utils import cache_static, timer -from gittip.elsewhere import platform_classes from aspen import log_dammit @@ -45,15 +44,8 @@ gittip.wireup.username_restrictions(website) gittip.wireup.nanswers() gittip.wireup.envvars(website) -gittip.wireup.platforms(website) tell_sentry = gittip.wireup.make_sentry_teller(website) -# this serves two purposes: -# 1) ensure all platform classes are created (and thus added to platform_classes) -# 2) keep the platform modules around to be added to the context below -platform_modules = {platform: import_module("gittip.elsewhere.%s" % platform) - for platform in platform_classes} - # The homepage wants expensive queries. Let's periodically select into an # intermediate table. @@ -126,7 +118,6 @@ def log_busy_threads(): def add_stuff_to_context(request): request.context['username'] = None - request.context.update(platform_modules) def scab_body_onto_response(response): diff --git a/default_local.env b/default_local.env index 3f7c2cc160..eeea0701a5 100644 --- a/default_local.env +++ b/default_local.env @@ -16,35 +16,34 @@ STRIPE_PUBLISHABLE_API_KEY=1 BALANCED_API_SECRET=90bb3648ca0a11e1a977026ba7e239a9 +DEBUG=1 + GITHUB_CLIENT_ID=3785a9ac30df99feeef5 GITHUB_CLIENT_SECRET=e69825fafa163a0b0b6d2424c107a49333d46985 GITHUB_CALLBACK=http://127.0.0.1:8537/on/github/associate -BITBUCKET_API_URL=https://bitbucket.org/api/1.0 BITBUCKET_CONSUMER_KEY=b8yzpsurhsmJufUqzh BITBUCKET_CONSUMER_SECRET=WF3q2g7naRHeeGUjnxyRwPLVhMBU4dmP BITBUCKET_CALLBACK=http://127.0.0.1:8537/on/bitbucket/associate -TWITTER_API_URL=https://api.twitter.com TWITTER_CONSUMER_KEY=QBB9vEhxO4DFiieRF68zTA TWITTER_CONSUMER_SECRET=mUymh1hVMiQdMQbduQFYRi79EYYVeOZGrhj27H59H78 -TWITTER_ACCESS_TOKEN=34175404-G6W8Hh19GWuUhIMEXK0LyZsy7N9aCMcy1bYJ9rI -TWITTER_ACCESS_TOKEN_SECRET=K6wxV1OCsihZAkEPkWtoLYDiRJnWajBBWn4UgliTRQ TWITTER_CALLBACK=http://127.0.0.1:8537/on/twitter/associate BOUNTYSOURCE_API_SECRET=e2BbqjNY60kC7V-Uq1dv2oHgGavbWm9pUJmiRHCApFZHDiY9aZyAspInhZaZ94x9 -BOUNTYSOURCE_API_HOST=https://staging-qa.bountysource.com -BOUNTYSOURCE_WWW_HOST=https://staging.bountysource.com BOUNTYSOURCE_CALLBACK=http://127.0.0.1:8537/on/bountysource/associate +BOUNTYSOURCE_API_HOST=https://staging-api.bountysource.com +BOUNTYSOURCE_WWW_HOST=https://staging.bountysource.com VENMO_CLIENT_ID=1534 VENMO_CLIENT_SECRET=55ckgsguYC3cj7xWW5c95PHvUzrwgZMA VENMO_CALLBACK=http://127.0.0.1:8537/on/venmo/associate -OPENSTREETMAP_API=http://master.apis.dev.openstreetmap.org OPENSTREETMAP_CONSUMER_KEY=J2SS5GM0A7tM1CIBjAHXUTMeCEkRBMYsTJzGONxe OPENSTREETMAP_CONSUMER_SECRET=hgvZkbtWVOEoaJV5AzQPcBI9m8f7BylkpT0cP7wS OPENSTREETMAP_CALLBACK=http://127.0.0.1:8537/on/openstreetmap/associate +OPENSTREETMAP_API_URL=http://master.apis.dev.openstreetmap.org/api/0.6 +OPENSTREETMAP_AUTH_URL=http://master.apis.dev.openstreetmap.org NANSWERS_THRESHOLD=2 diff --git a/default_tests.env b/default_tests.env index e9581cc297..026d1fc559 100644 --- a/default_tests.env +++ b/default_tests.env @@ -16,35 +16,34 @@ STRIPE_PUBLISHABLE_API_KEY=1 BALANCED_API_SECRET=90bb3648ca0a11e1a977026ba7e239a9 +DEBUG=1 + GITHUB_CLIENT_ID=3785a9ac30df99feeef5 GITHUB_CLIENT_SECRET=e69825fafa163a0b0b6d2424c107a49333d46985 GITHUB_CALLBACK=http://127.0.0.1:8537/on/github/associate -BITBUCKET_API_URL=https://bitbucket.org/api/1.0 BITBUCKET_CONSUMER_KEY=b8yzpsurhsmJufUqzh BITBUCKET_CONSUMER_SECRET=WF3q2g7naRHeeGUjnxyRwPLVhMBU4dmP BITBUCKET_CALLBACK=http://127.0.0.1:8537/on/bitbucket/associate -TWITTER_API_URL=https://api.twitter.com TWITTER_CONSUMER_KEY=QBB9vEhxO4DFiieRF68zTA TWITTER_CONSUMER_SECRET=mUymh1hVMiQdMQbduQFYRi79EYYVeOZGrhj27H59H78 -TWITTER_ACCESS_TOKEN=34175404-G6W8Hh19GWuUhIMEXK0LyZsy7N9aCMcy1bYJ9rI -TWITTER_ACCESS_TOKEN_SECRET=K6wxV1OCsihZAkEPkWtoLYDiRJnWajBBWn4UgliTRQ TWITTER_CALLBACK=http://127.0.0.1:8537/on/twitter/associate BOUNTYSOURCE_API_SECRET=e2BbqjNY60kC7V-Uq1dv2oHgGavbWm9pUJmiRHCApFZHDiY9aZyAspInhZaZ94x9 -BOUNTYSOURCE_API_HOST=https://staging-qa.bountysource.com -BOUNTYSOURCE_WWW_HOST=https://staging.bountysource.com BOUNTYSOURCE_CALLBACK=http://127.0.0.1:8537/on/bountysource/associate +BOUNTYSOURCE_API_HOST=https://staging-api.bountysource.com +BOUNTYSOURCE_WWW_HOST=https://staging.bountysource.com VENMO_CLIENT_ID=1534 VENMO_CLIENT_SECRET=55ckgsguYC3cj7xWW5c95PHvUzrwgZMA VENMO_CALLBACK=http://127.0.0.1:8537/on/venmo/associate -OPENSTREETMAP_API=http://master.apis.dev.openstreetmap.org OPENSTREETMAP_CONSUMER_KEY=J2SS5GM0A7tM1CIBjAHXUTMeCEkRBMYsTJzGONxe OPENSTREETMAP_CONSUMER_SECRET=hgvZkbtWVOEoaJV5AzQPcBI9m8f7BylkpT0cP7wS OPENSTREETMAP_CALLBACK=http://127.0.0.1:8537/on/openstreetmap/associate +OPENSTREETMAP_API_URL=http://master.apis.dev.openstreetmap.org/api/0.6 +OPENSTREETMAP_AUTH_URL=http://master.apis.dev.openstreetmap.org NANSWERS_THRESHOLD=2 diff --git a/gittip/elsewhere/__init__.py b/gittip/elsewhere/__init__.py index 9574268536..f4a4727017 100644 --- a/gittip/elsewhere/__init__.py +++ b/gittip/elsewhere/__init__.py @@ -1,363 +1,269 @@ """This subpackage contains functionality for working with accounts elsewhere. """ -from __future__ import print_function, unicode_literals +from __future__ import division, print_function, unicode_literals from collections import OrderedDict -from urlparse import parse_qs - -from aspen import json, log, Response, resources -from aspen.utils import typecheck +from datetime import datetime +import hashlib +import json +import logging +from urllib import quote +import xml.etree.ElementTree as ET + +from aspen import log, Response +from aspen.utils import to_age, typecheck, utc from psycopg2 import IntegrityError import requests -from requests_oauthlib import OAuth1 +from requests_oauthlib import OAuth1Session, OAuth2Session +import xmltodict import gittip -from gittip.exceptions import ProblemChangingUsername, UnknownPlatform -from gittip.models._mixin_elsewhere import NeedConfirmation -from gittip.models.account_elsewhere import AccountElsewhere from gittip.utils.username import reserve_a_random_username +from gittip.elsewhere._extractors import * -ACTIONS = ['opt-in', 'connect', 'lock', 'unlock'] - - -# Exceptions -# ========== - -class UnknownAccountElsewhere(Exception): - pass -class BadAccountElsewhereSubclass(Exception): - def __str__(self): - return "The Platform subclass {} specifies an account_elsewhere_subclass that " \ - "doesn't subclass AccountElsewhere.".format(self.args[0]) +ACTIONS = {'opt-in', 'connect', 'lock', 'unlock'} +PLATFORMS = 'bitbucket bountysource github openstreetmap twitter venmo'.split() -class MissingAttributes(Exception): - def __str__(self): - return "The Platform subclass {} is missing: {}."\ - .format(self.args[0], ', '.join(self.args[1])) +class UnknownAccountElsewhere(Exception): pass -# Platform Objects -# ================ class PlatformRegistry(object): - """Registry of platforms we support connecting to your Gittip account. + """Registry of platforms we support connecting to Gittip accounts. """ + def __init__(self, platforms): + self.__dict__ = OrderedDict((p.name, p) for p in platforms) - def __init__(self, db): - self.db = db - - def get(self, name, default=None): - return getattr(self, name, default) - - def __getitem__(self, name): - platform = self.get(name) - if platform is None: - raise KeyError(name) - return platform - - def register(self, *Platforms): - for Platform in Platforms: - platform = Platform(self.db) - self.__dict__[platform.name] = platform - AccountElsewhere.subclasses[platform.name] = platform.account_elsewhere_subclass + def __iter__(self): + return iter(self.__dict__.values()) class Platform(object): - def __init__(self, db): + # "x" stands for "extract" + x_user_info = lambda self, info: info + x_user_id = not_available + x_user_name = not_available + x_display_name = not_available + x_email = not_available + x_gravatar_id = not_available + x_avatar_url = not_available + + required_attrs = ( 'account_url' + , 'display_name' + , 'icon' + , 'name' + ) + + def __init__(self, db, api_key, api_secret, callback_url, api_url=None, auth_url=None): self.db = db - + self.api_key = api_key + self.api_secret = api_secret + self.callback_url = callback_url + if api_url: + self.api_url = api_url + if auth_url: + self.auth_url = auth_url + elif not getattr(self, 'auth_url', None): + self.auth_url = api_url # Make sure the subclass was implemented properly. - # ================================================ - - expected_attrs = ( 'account_elsewhere_subclass' - , 'get_user_info' - , 'name' - , 'username_key' - , 'user_id_key' - ) - missing_attrs = [] - for attr in expected_attrs: - if not hasattr(self, attr): - missing_attrs.append(attr) + missing_attrs = [a for a in self.required_attrs if not hasattr(self, a)] if missing_attrs: - raise MissingAttributes(self.__class__.__name__, missing_attrs) - - if not issubclass(self.account_elsewhere_subclass, AccountElsewhere): - raise BadAccountElsewhereSubclass(self.account_elsewhere_subclass) - - return output - - def get_account(self, username): - """Given a username on the other platform, return an AccountElsewhere object. + msg = "The class %s is missing these required attributes: %s" + msg %= self.__class__.__name__, ', '.join(missing_attrs) + raise AttributeError(msg) + + def get(self, path, sess=None, **kw): + if not sess: + sess = self.get_auth_session() + response = sess.get(self.api_url+path, **kw) + + # Check status + status = response.status_code + if status == 404: + raise Response(404) + elif status != 200: + log('{} api responded with {}:\n{}'.format(self.name, status, content) + , level=logging.ERROR) + raise Response(500, '{} lookup failed with {}'.format(self.name, status)) + + # Check ratelimit headers + prefix = getattr(self, 'ratelimit_headers_prefix', None) + if prefix: + limit = response.headers[prefix+'limit'] + remaining = response.headers[prefix+'remaining'] + reset = response.headers[prefix+'reset'] + try: + limit, remaining, reset = int(limit), int(remaining), int(reset) + except (TypeError, ValueError): + d = dict(limit=limit, remaining=remaining, reset=reset) + log('Got weird rate headers from %s: %s' % (self.name, d)) + else: + percent_remaining = remaining/limit + if percent_remaining < 0.5: + reset = to_age(datetime.fromtimestamp(reset, tz=utc)) + log_msg = ( + '{0} API: {1:.1%} of ratelimit has been consumed, ' + '{2} requests remaining, resets {3}.' + ).format(self.name, 1 - percent_remaining, remaining, reset) + log_lvl = logging.WARNING + if percent_remaining < 0.2: + log_lvl = logging.ERROR + elif percent_remaining < 0.05: + log_lvl = logging.CRITICAL + log(log_msg, log_lvl) + + return response + + def get_account(self, user_name): + """Given a user_name on the platform, return an AccountElsewhere object. """ try: - out = self.get_account_from_db(username) + return self.get_account_from_db(user_name) except UnknownAccountElsewhere: - out = self.get_account_from_api(username) - return out + return self.get_account_from_api(user_name) - def get_account_from_db(self, username): - """Given a username on the other platform, return an AccountElsewhere object. + def get_account_from_api(self, user_name): + return self.upsert(*self.get_user_info(user_name)) - If the account elsewhere is unknown to us, we raise UnknownAccountElsewhere. + def get_account_from_db(self, user_name): + """Given a user_name on the platform, return an AccountElsewhere object. + If the account is unknown to us, we raise UnknownAccountElsewhere. """ return self.db.one(""" SELECT elsewhere.*::elsewhere_with_participant FROM elsewhere - WHERE platform=%s - AND user_info->%s = %s + WHERE platform = %s + AND user_name = %s - """, (self.name, self.username_key, username), default=UnknownAccountElsewhere) + """, (self.name, user_name), default=UnknownAccountElsewhere) - def get_account_from_api(self, username): - """Given a username on the other platform, return an AccountElsewhere object. + def get_user_info(self, user_name, sess=None): + path = self.api_user_info_path.format(user_name=quote(user_name)) + response = self.get(path, sess=sess) + return self.parse_user_info(response) - This method always hits the API and updates our database. + def get_user_self_info(self, sess): + response = self.get(self.api_user_self_info_path, sess=sess) + return self.parse_user_info(response) - """ - user_id, user_info = self._get_account_from_api(username) - return self.upsert(user_id, user_info) + def parse_user_info(self, response): + if self.api_format == 'json': + info = response.json() + elif self.api_format == 'xml': + info = ET.fromstring(response.content) + else: + raise ValueError('unknown API format: %(api_format)s' % self) + return self.extract_user_info(info) + + def extract_user_info(self, info): + info = self.x_user_info(info) + user_id = unicode(self.x_user_id(info)) + user_name = self.x_user_name(info) + display_name = self.x_display_name(info, None) + email = self.x_email(info, None) + gravatar_id = self.x_gravatar_id(info, None) + if email and not gravatar_id: + gravatar_id = hashlib.md5(email.strip().lower()).hexdigest() + if gravatar_id: + avatar_url = 'https://www.gravatar.com/avatar/'+gravatar_id + else: + avatar_url = self.x_avatar_url(info, None) + return user_id, user_name, display_name, email, avatar_url, info - def _get_account_from_api(self, username): - # Factored out so we can call upsert without hitting API for testing. - user_info = self.get_user_info(username) - user_id = unicode(user_info[self.user_id_key]) # If this is KeyError, then what? - return user_id, user_info + def save_token(self, user_id, token, refresh_token=None, expires=None): + """Saves the given access token in the database. + """ + self.db.run(""" + UPDATE elsewhere + SET (access_token, refresh_token, expires) = (%s, %s, %s) + WHERE platform=%s AND user_id=%s + """, (token, refresh_token, expires, self.name, user_id)) - def upsert(self, user_id, user_info): - """Given a unicode and a dict, dance with our db and return an AccountElsewhere. + def upsert(self, user_id, user_name, display_name, email, avatar_url, extra_info): + """Insert or update the user's info. """ - typecheck(user_id, unicode, user_info, dict) + typecheck(user_id, unicode) + + if isinstance(extra_info, ET.Element): + extra_info = xmltodict.parse(ET.tostring(extra_info)) + extra_info = json.dumps(extra_info) + cols = 'user_id, user_name, display_name, email, avatar_url, extra_info' + args = (user_id, user_name, display_name, email, avatar_url, extra_info) - # Insert the account if needed. - # ============================= - # Do this with a transaction so that if the insert fails, the - # participant we reserved for them is rolled back as well. try: + # Try to insert the account + # We do this with a transaction so that if the insert fails, the + # participant we reserved for them is rolled back as well. with self.db.get_cursor() as cursor: random_username = reserve_a_random_username(cursor) - cursor.execute( "INSERT INTO elsewhere " - "(platform, user_id, participant) " - "VALUES (%s, %s, %s)" - , (self.name, user_id, random_username) - ) + cursor.execute(""" + INSERT INTO elsewhere + (participant, platform, {0}) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """.format(cols), (random_username, self.name)+args) except IntegrityError: - # We have a db-level uniqueness constraint on (platform, user_id) - pass - - # Update their user_info. - # ======================= - # Cast everything to unicode, because (I believe) hstore can take any - # type of value, but psycopg2 can't. - # - # https://postgres.heroku.com/blog/past/2012/3/14/introducing_keyvalue_data_storage_in_heroku_postgres/ - # http://initd.org/psycopg/docs/extras.html#hstore-data-type - # - # XXX This clobbers things, of course, such as booleans. See - # /on/bitbucket/%username/index.html - for k, v in user_info.items(): - user_info[k] = unicode(v) - - username = self.db.one(""" - - UPDATE elsewhere - SET user_info=%s - WHERE platform=%s AND user_id=%s - RETURNING user_info->%s AS username - - """, (user_info, self.name, user_id, self.username_key)) + # The account is already in the DB, update it instead + self.db.run(""" + UPDATE elsewhere + SET ({0}) = (%s, %s, %s, %s, %s, %s) + WHERE platform=%s AND user_id=%s + """.format(cols), args+(self.name, user_id)) # Now delegate to get_account_from_db - return self.get_account_from_db(username) - - def resolve(self, username): - """Given a username elsewhere, return a username here. - """ - typecheck(username, unicode) - participant = self.db.one(""" - - SELECT participant - FROM elsewhere - WHERE platform=%s - AND user_info->%s = %s - - """, (self.name, self.username_key, username,)) - # XXX Do we want a uniqueness constraint on $username_key? Can we do that? - - if participant is None: - raise Exception( "User %s on %s isn't known to us." - % (username, self.platform) - ) - return participant - - def user_action(self, request, website, user, then, action, user_info): - cookie = request.headers.cookie - - # Make sure we have a Platform username. - username = user_info.get(self.username_key) - if username is None: - log(u"We got a user_info from %s with no username (%s) [%s, %s]" - % (self.name, self.username_key, action, then)) - raise Response(400) - - # Do something. - log(u"%s wants to %s" % (username, action)) - - account = self.get_account_from_api(username) - - if action == 'opt-in': # opt in - # set 'user' to give them a session :/ - user, newly_claimed = account.opt_in(username) - del account - elif action == 'connect': # connect - try: - user.participant.take_over(account) - except NeedConfirmation, obstacles: - - # XXX Eep! Internal redirect! Really?! - request.internally_redirected_from = request.fs - request.fs = website.www_root + '/on/confirm.html.spt' - request.resource = resources.get(request) - - raise request.resource.respond(request) - else: - del account - else: # lock or unlock - if then != username: - - # The user could spoof `then' to match their username, but the most - # they can do is lock/unlock their own Platform account in a convoluted - # way. - - then = u'/on/%s/%s/lock-fail.html' % (self.name, then) - - else: - - # Associate the Platform username with a randomly-named, unclaimed - # Gittip participant. - - assert account.participant != username, username # sanity check - account.set_is_locked(action == 'lock') - del account - - if then == u'': - then = u'/%s/' % user.participant.username - if not then.startswith(u'/'): - # Interpret it as a Platform username. - then = u'/on/%s/%s/' % (self.name, then) - return then, user + return self.get_account_from_db(user_name) class PlatformOAuth1(Platform): - def get_oauth_init_url(self, redirect_uri, qs, website): - oauth_hook = OAuth1( - getattr(website, '%s_consumer_key' % self.name), - getattr(website, '%s_consumer_secret' % self.name), - ) - - response = requests.post( - "%s/oauth/request_token" % self.api_url, - data={'oauth_callback': redirect_uri}, - auth=oauth_hook, - ) - - assert response.status_code == 200, response.status_code # safety check + def get_auth_session(self, token=None, token_secret=None): + return OAuth1Session(self.api_key, self.api_secret, token, token_secret, + callback_uri=self.callback_url) - reply = parse_qs(response.text) + def get_auth_url(self, **kw): + sess = self.get_auth_session() + r = sess.fetch_request_token(self.auth_url+'/oauth/request_token') + url = sess.authorization_url(self.auth_url+'/oauth/authorize') + return url, r['oauth_token'], r['oauth_token_secret'] - token = reply['oauth_token'][0] - secret = reply['oauth_token_secret'][0] - assert reply['oauth_callback_confirmed'][0] == "true" # sanity check + def get_query_id(self, querystring): + return querystring['oauth_token'] - action = qs.get('action', 'opt-in') - then = qs.get('then', '') - website.oauth_cache = {} # XXX What happens to someone who was half-authed - # when we bounced the server? - website.oauth_cache[token] = (secret, action, then) - - url = "%s/oauth/authenticate?oauth_token=%s" - return url % (self.api_url, token) - - def handle_oauth_callback(self, request, website, user): - qs = request.line.uri.querystring - - if 'denied' in qs or not ('oauth_token' in qs and 'oauth_verifier' in qs): - raise Response(403) - - token = qs['oauth_token'] - try: - secret, action, then = website.oauth_cache.pop(token) - then = then.decode('base64') - except KeyError: - return '/about/me.html', user - - if action not in ACTIONS: - raise Response(400) - - if action == 'connect' and user.ANON: - raise Response(404) - - oauth = OAuth1( - getattr(website, '%s_consumer_key' % self.name), - getattr(website, '%s_consumer_secret' % self.name), - token, - secret, - ) - response = requests.post( - "%s/oauth/access_token" % self.api_url, - data={"oauth_verifier": qs['oauth_verifier']}, - auth=oauth, - ) - assert response.status_code == 200, response.status_code - - reply = parse_qs(response.text) - token = reply['oauth_token'][0] - secret = reply['oauth_token_secret'][0] - if self.username_key in reply: - username = reply[self.username_key][0] - else: - username = None - - user_info = self.get_user_info(username, token, secret) - - return self.user_action(request, website, user, then, action, user_info) + def handle_auth_callback(self, url, token, token_secret): + sess = self.get_auth_session(token=token, token_secret=token_secret) + r = sess.parse_authorization_response(url) + sess.fetch_access_token(self.auth_url+'/oauth/access_token') + return sess class PlatformOAuth2(Platform): - def handle_oauth_callback(self, request, website, user): - qs = request.line.uri.querystring + oauth_email_scope = None + oauth_payment_scope = None - if 'error' in qs or not ('code' in qs and 'data' in qs): - raise Response(403) + def __init__(self, *args, **kw): + super(PlatformOAuth2, self).__init__(*args, **kw) + scope = [self.oauth_email_scope, self.oauth_payment_scope] + self.scope = list(filter(None, scope)) - # Determine what we're supposed to do. - data = qs['data'].decode('base64').decode('UTF-8') - action, then = data.split(',', 1) - if action not in ACTIONS: - raise Response(400) + def get_auth_session(self, state=None, token=None): + return OAuth2Session(self.api_key, state=state, token=token, + redirect_uri=self.callback_url, scope=self.scope) - if action == 'connect' and user.ANON: - raise Response(404) - - # Load user info. - user_info = self.oauth_dance(website, qs) + def get_auth_url(self, **kw): + sess = self.get_auth_session() + url, state = sess.authorization_url(self.auth_url+'/authorize') + return url, state, '' - return self.user_action(request, website, user, then, action, user_info) + def get_query_id(self, querystring): + return querystring['state'] - def set_oauth_tokens(self, access_token, refresh_token, expires): - """ - Updates the elsewhere row with the given access token, refresh token, and Python datetime - """ - - self.db.run(""" - UPDATE elsewhere - SET (access_token, refresh_token, expires) - = (%s, %s, %s) - WHERE platform=%s AND user_id=%s - """, (access_token, refresh_token, expires, self.platform, self.user_id)) + def handle_auth_callback(self, url, state, unused_arg): + sess = self.get_auth_session(state=state) + sess.fetch_token(self.auth_url+'/access_token', + client_secret=self.api_secret, + authorization_response=url) + return sess diff --git a/gittip/elsewhere/_extractors.py b/gittip/elsewhere/_extractors.py new file mode 100644 index 0000000000..c051ea9c1b --- /dev/null +++ b/gittip/elsewhere/_extractors.py @@ -0,0 +1,68 @@ +"""Helper functions to extract data from API responses +""" +from __future__ import unicode_literals + +import json +import xml.etree.ElementTree as ET + +from aspen import log + + +def key(k, clean=lambda a: a): + def f(self, info, *default): + try: + v = info.pop(k, *default) + except KeyError: + msg = 'Unable to find key "%s" in %s API response:\n%s' + log(msg % (k, self.name, json.dumps(info, indent=4))) + raise + if v: + v = clean(v) + if not v and not default: + msg = 'Key "%s" has an empty value in %s API response:\n%s' + msg %= (k, self.name, json.dumps(info, indent=4)) + log(msg) + raise ValueError(msg) + return v + return f + + +def not_available(self, info, *default): + if default: + return default[0] + return None + + +def xpath(path, attr=None, clean=lambda a: a): + def f(self, info, *default): + try: + l = info.findall(path) + if len(l) > 1: + msg = 'The xpath "%s" matches more than one element in %s API response:\n%s' + msg %= (path, self.name, ET.tostring(info)) + log(msg) + raise ValueError(msg) + v = l[0].get(attr) if attr else l[0] + except IndexError: + if default: + return default[0] + msg = 'Unable to find xpath "%s" in %s API response:\n%s' + msg %= (path, self.name, ET.tostring(info)) + log(msg) + raise IndexError(msg) + except KeyError: + if default: + return default[0] + msg = 'The element has no "%s" attribute in %s API response:\n%s' + msg %= (attr, self.name, ET.tostring(info)) + log(msg) + raise KeyError(msg) + if v: + v = clean(v) + if not v and not default: + msg = 'The xpath "%s" points to an empty value in %s API response:\n%s' + msg %= (path, self.name, ET.tostring(info)) + log(msg) + raise ValueError(msg) + return v + return f diff --git a/gittip/elsewhere/bitbucket.py b/gittip/elsewhere/bitbucket.py index 32bc173bb0..fb0b136d56 100644 --- a/gittip/elsewhere/bitbucket.py +++ b/gittip/elsewhere/bitbucket.py @@ -1,109 +1,30 @@ from __future__ import absolute_import, division, print_function, unicode_literals import logging -from os import environ -from aspen import json, log, Response -from aspen.http.request import PathPart -from aspen.utils import typecheck -from gittip.elsewhere import AccountElsewhere, PlatformOAuth1 +from aspen import log, Response import requests from requests_oauthlib import OAuth1 - -BASE_API_URL = "https://bitbucket.org/api/1.0" - - -class BitbucketAccount(AccountElsewhere): - - @property - def html_url(self): - return "https://bitbucket.org/{username}".format(**self.user_info) - - @property - def display_name(self): - return self.user_info['username'] - - def get_platform_icon(self): - return "/assets/icons/bitbucket.12.png" - - @property - def img_src(self): - src = '' - # XXX Um ... ? - return src +from gittip.elsewhere import PlatformOAuth1, key, not_available class Bitbucket(PlatformOAuth1): + # Platform attributes name = 'bitbucket' - account_elsewhere_subclass = BitbucketAccount - username_key = 'username' - user_id_key = 'username' # No immutable id. :-/ - api_url = environ['BITBUCKET_API_URL'] - - - def oauth_url(self, action, then=""): - """Return a URL to start oauth dancing with Bitbucket. - - For GitHub we can pass action and then through a querystring. For Bitbucket - we can't, so we send people through a local URL first where we stash this - info in an in-memory cache (eep! needs refactoring to scale). - """ - then = then.encode('base64').strip() - return "/on/bitbucket/redirect?action=%s&then=%s" % (action, then) - - - def get_user_info(self, username, token=None, secret=None): - """Get the given user's information from the DB or failing that, bitbucket. - - :param username: - A unicode string representing a username in bitbucket. - - :param token: - OAuth1 token. - - :param secret: - OAuth1 secret. - - :returns: - A dictionary containing bitbucket specific information for the user. - """ - if not username and token and secret: - return self.get_user_info_with_oauth(token, secret) - - url = "%s/users/%s?pagelen=100" - user_info = requests.get(url % (BASE_API_URL, username)) - status = user_info.status_code - content = user_info.content - if status == 200: - user_info = json.loads(content)['user'] - elif status == 404: - raise Response(404, - "Bitbucket identity '{0}' not found.".format(username)) - else: - log("Bitbucket api responded with {0}: {1}".format(status, content), - level=logging.WARNING) - raise Response(502, "Bitbucket lookup failed with %d." % status) - - return user_info - - def get_user_info_with_oauth(self, token, secret): - oauth_hook = OAuth1( - environ['BITBUCKET_CONSUMER_KEY'], - environ['BITBUCKET_CONSUMER_SECRET'], - token, - secret, - ) - response = requests.get( - "%s/user" % self.api_url, - auth=oauth_hook, - ) - user_info = json.loads(response.text)['user'] - assert response.status_code == 200, response.status_code - - assert 'html_url' not in user_info - # Add user page url. - user_info['html_url'] = "https://bitbucket.org/" + user_info[self.username_key] - - return user_info + display_name = 'Bitbucket' + account_url = 'https://bitbucket.org/{user_name}' + icon = '/assets/icons/bitbucket.12.png' + + # API attributes + api_format = 'json' + api_url = 'https://bitbucket.org/api/1.0' + api_user_info_path = '/users/{user_name}?pagelen=100' + api_user_self_info_path = '/user' + x_user_info = key('user') + x_user_id = key('username') # No immutable id. :-/ + x_user_name = key('username') + x_display_name = key('display_name') + x_email = not_available + x_avatar_url = key('avatar') diff --git a/gittip/elsewhere/bountysource.py b/gittip/elsewhere/bountysource.py index d06c7c2b3b..c3159c4003 100644 --- a/gittip/elsewhere/bountysource.py +++ b/gittip/elsewhere/bountysource.py @@ -1,107 +1,84 @@ -import os -import md5 -import time -from gittip.models.participant import Participant -from gittip.elsewhere import AccountElsewhere - - -class BountysourceAccount(AccountElsewhere): - platform = u'bountysource' - - def get_user_name(self): - return self.user_info['display_name'] - - def get_platform_icon(self): - return "/assets/icons/bountysource.12.png" - -def oauth_url(website, participant, redirect_url=None): - """Return a URL to authenticate with Bountysource. - - :param participant: - The participant whose account is being linked - - :param redirect_url: - Optional redirect URL after authentication. Defaults to value defined - in local.env - - :returns: - URL for Bountysource account authorization - """ - if redirect_url: - return "/on/bountysource/redirect?redirect_url=%s" % redirect_url - else: - return "/on/bountysource/redirect" - - -# Bountysource Access Tokens -# ========================== - -def create_access_token(participant): - """Return an access token for the Bountysource API for this user. - """ - time_now = int(time.time()) - token = "%s.%s.%s" % ( participant.id - , time_now - , hash_access_token(participant.id, time_now) - ) - return token - +from __future__ import absolute_import, division, print_function, unicode_literals -def hash_access_token(user_id, time_now): - """Create hash for access token. - - :param user_id: - ID of the user. - - :param time_now: - Current time, in seconds, as an integer. - - :returns: - MD5 hash of user_id, time, and Bountysource API secret - """ - raw = "%s.%s.%s" % ( user_id - , time_now - , os.environ['BOUNTYSOURCE_API_SECRET'].decode('ASCII') - ) - return md5.new(raw).hexdigest() - - -def access_token_valid(access_token): - """Helper method to check validity of access token. - """ - parts = (access_token or '').split('.') - return len(parts) == 3 and parts[2] == \ - hash_access_token(parts[0], parts[1]) - - -def get_participant_via_access_token(access_token): - """From a Gittip access token, attempt to find an external account - - :param access_token: - access token generated by Gittip on account link redirect - - :returns: - the participant, if found - """ - if access_token_valid(access_token): - parts = access_token.split('.') - participant_id = parts[0] - return Participant.from_id(participant_id) - - -def filter_user_info(user_info): - """Filter the user info dictionary for a Bountysource account. +from binascii import hexlify +import hashlib +import os +from time import time +from urllib import urlencode +from urlparse import parse_qs, urlparse - This is so that the Bountysource access token doesn't float around in a - user_info hash (considering nothing else does that). +import requests - """ - whitelist = ['id', 'display_name', 'first_name', 'last_name', 'email', \ - 'frontend_url', 'image_url'] - filtered_user_info = {} - for key in user_info: - if key in whitelist: - filtered_user_info[key] = user_info[key] +from gittip.elsewhere import Platform, key, not_available +from gittip.models.participant import Participant - return filtered_user_info +class Bountysource(Platform): + + # Platform attributes + name = 'bountysource' + display_name = 'Bountysource' + account_url = '{platform_data.auth_url}/people/{user_id}' + icon = '/assets/icons/bountysource.12.png' + + # API attributes + api_format = 'json' + api_user_info_path = '/users/{user_id}' + api_user_self_info_path = '/user' + x_user_id = key('id') + x_user_name = key('display_name') + x_display_name = not_available + x_email = key('email') + x_avatar_url = key('image_url') + + def get_auth_session(self, token=None): + sess = requests.Session() + sess.auth = BountysourceAuth(token) + return sess + + def get_auth_url(self, user): + query_id = hexlify(os.urandom(10)) + time_now = int(time()) + raw = '%s.%s.%s' % (user.participant.id, time_now, self.api_secret) + h = hashlib.md5(raw).hexdigest() + token = '%s.%s.%s' % (user.participant.id, time_now, h) + params = dict( + redirect_url=self.callback_url+'?query_id='+query_id, + external_access_token=token + ) + url = self.auth_url+'/auth/gittip/confirm?'+urlencode(params) + return url, query_id, '' + + def get_query_id(self, querystring): + token = querystring['access_token'] + i = token.rfind('.') + data, data_hash = token[:i], token[i+1:] + if data_hash != hashlib.md5(data+'.'+self.api_secret).hexdigest(): + raise Response(400, 'Invalid hash in access_token') + return querystring['query_id'] + + def get_user_info(self, sess): + raise NotImplementedError() + + def get_user_self_info(self, sess): + querystring = urlparse(sess._callback_url).query + info = {k: v[0] if len(v) == 1 else v + for k, v in parse_qs(querystring).items()} + info.pop('access_token') + info.pop('query_id') + return self.extract_user_info(info) + + def handle_auth_callback(self, url, query_id, unused_arg): + sess = self.get_auth_session(token=query_id) + sess._callback_url=url + return sess + + +class BountysourceAuth(object): + + def __init__(self, token=None): + self.token = token + + def __call__(self, req): + if self.token: + req.params['access_token'] = self.token diff --git a/gittip/elsewhere/github.py b/gittip/elsewhere/github.py index 6dd1532a06..20d9d81b56 100644 --- a/gittip/elsewhere/github.py +++ b/gittip/elsewhere/github.py @@ -1,162 +1,34 @@ from __future__ import absolute_import, division, print_function, unicode_literals import logging -import os import requests -from aspen import json, Response -from aspen.http.request import PathPart -from aspen.utils import typecheck -from aspen.website import Website +from aspen import Response from gittip import log -from gittip.elsewhere import ACTIONS, AccountElsewhere, PlatformOAuth2 - - -class GitHubAccount(AccountElsewhere): - - @property - def html_url(self): - return self.user_info['html_url'] - - @property - def display_name(self): - return self.user_info['login'] - - def get_platform_icon(self): - return "/assets/icons/github.12.png" - - @property - def img_src(self): - src = '' - - # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ - if 'gravatar_id' in self.user_info: - gravatar_hash = self.user_info['gravatar_id'] - src = "https://www.gravatar.com/avatar/%s.jpg?s=%s" - src %= (gravatar_hash, 128) - - return src +from gittip.elsewhere import PlatformOAuth2, key, not_available class GitHub(PlatformOAuth2): + # Platform attributes name = 'github' - account_elsewhere_subclass = GitHubAccount - username_key = 'login' - user_id_key = 'id' - - - def oauth_url(self, website, action, then=u""): - """Given a website object and a string, return a URL string. - - `action' is one of 'opt-in', 'lock' and 'unlock' - - `then' is either a github username or an URL starting with '/'. It's - where we'll send the user after we get the redirect back from - GitHub. - - """ - typecheck(website, Website, action, unicode, then, unicode) - assert action in ACTIONS - url = u"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s" - url %= (website.github_client_id, website.github_callback) - - # Pack action,then into data and base64-encode. Querystring isn't - # available because it's consumed by the initial GitHub request. - - data = u'%s,%s' % (action, then) - data = data.encode('UTF-8').encode('base64').strip().decode('US-ASCII') - url += u'?data=%s' % data - return url - - - def oauth_dance(self, website, qs): - """Given a querystring, return a dict of user_info. - - The querystring should be the querystring that we get from GitHub when - we send the user to the return value of oauth_url above. - - See also: - - http://developer.github.com/v3/oauth/ - - """ - - log("Doing an OAuth dance with Github.") - - data = { 'code': qs['code'].encode('US-ASCII') - , 'client_id': website.github_client_id - , 'client_secret': website.github_client_secret - } - r = requests.post("https://github.com/login/oauth/access_token", data=data) - assert r.status_code == 200, (r.status_code, r.text) - - back = dict([pair.split('=') for pair in r.text.split('&')]) # XXX - if 'error' in back: - raise Response(400, back['error'].encode('utf-8')) - assert back.get('token_type', '') == 'bearer', back - access_token = back['access_token'] - - r = requests.get( "https://api.github.com/user" - , headers={'Authorization': 'token %s' % access_token} - ) - assert r.status_code == 200, (r.status_code, r.text) - user_info = json.loads(r.text) - log("Done with OAuth dance with Github for %s (%s)." - % (user_info['login'], user_info['id'])) - - return user_info - - - def get_user_info(self, login): - """Get the given user's information from the DB or failing that, github. - - :param login: - A unicode string representing a username in github. - - :returns: - A dictionary containing github specific information for the user. - """ - - url = "https://api.github.com/users/%s" - user_info = requests.get(url % login, params={ - 'client_id': os.environ.get('GITHUB_CLIENT_ID'), - 'client_secret': os.environ.get('GITHUB_CLIENT_SECRET') - }) - status = user_info.status_code - content = user_info.text - - # Calculate how much of our ratelimit we have consumed - remaining = int(user_info.headers['x-ratelimit-remaining']) - limit = int(user_info.headers['x-ratelimit-limit']) - # thanks to from __future__ import division this is a float - percent_remaining = remaining/limit - - log_msg = '' - log_lvl = None - # We want anything 50% or over - if 0.5 <= percent_remaining: - log_msg = ("{0}% of GitHub's ratelimit has been consumed. {1}" - " requests remaining.").format(percent_remaining * 100, - remaining) - if 0.5 <= percent_remaining < 0.8: - log_lvl = logging.WARNING - elif 0.8 <= percent_remaining < 0.95: - log_lvl = logging.ERROR - elif 0.95 <= percent_remaining: - log_lvl = logging.CRITICAL - - if log_msg and log_lvl: - log(log_msg, log_lvl) - - if status == 200: - user_info = json.loads(content) - elif status == 404: - raise Response(404, - "GitHub identity '{0}' not found.".format(login)) - else: - log("Github api responded with {0}: {1}".format(status, content), - level=logging.WARNING) - raise Response(502, "GitHub lookup failed with %d." % status) - - return user_info + display_name = 'GitHub' + account_url = 'https://github.com/{user_name}' + icon = '/assets/icons/github.12.png' + + # Auth attributes + auth_url = 'https://github.com/login/oauth' + oauth_email_scope = 'user:email' + + # API attributes + api_format = 'json' + api_url = 'https://api.github.com' + api_user_info_path = '/users/{user_name}' + api_user_self_info_path = '/user' + ratelimit_headers_prefix = 'x-ratelimit-' + x_user_id = key('id') + x_user_name = key('login') + x_display_name = key('name') + x_email = key('email') + x_gravatar_id = key('gravatar_id') + x_avatar_url = key('avatar_url') diff --git a/gittip/elsewhere/openstreetmap.py b/gittip/elsewhere/openstreetmap.py index 7bb8116f6d..097844c833 100644 --- a/gittip/elsewhere/openstreetmap.py +++ b/gittip/elsewhere/openstreetmap.py @@ -1,72 +1,27 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + import logging -import gittip +from aspen import log, Response import requests -from aspen import json, log, Response -from aspen.http.request import PathPart -from aspen.utils import typecheck -from gittip.elsewhere import AccountElsewhere - - - -class OpenStreetMapAccount(AccountElsewhere): - platform = u'openstreetmap' - - def get_url(self): - return self.user_info['html_url'] - - def get_user_name(self): - return self.user_info['username'] - - def get_platform_icon(self): - return "/assets/icons/openstreetmap.12.png" - - -def oauth_url(website, action, then=""): - """Return a URL to start oauth dancing with OpenStreetMap. - - For GitHub we can pass action and then through a querystring. For OpenStreetMap - we can't, so we send people through a local URL first where we stash this - info in an in-memory cache (eep! needs refactoring to scale). - - Not sure why website is here. Vestige from GitHub forebear? - - """ - then = then.encode('base64').strip() - return "/on/openstreetmap/redirect?action=%s&then=%s" % (action, then) - -def get_user_info(db, username, osm_api_url): - """Get the given user's information from the DB or failing that, openstreetmap. +from gittip.elsewhere import PlatformOAuth1, not_available, xpath - :param username: - A unicode string representing a username in OpenStreetMap. - :param osm_api_url: - URL of OpenStreetMap API. +class OpenStreetMap(PlatformOAuth1): - :returns: - A dictionary containing OpenStreetMap specific information for the user. - """ - typecheck(username, (unicode, PathPart)) - rec = db.one(""" - SELECT user_info FROM elsewhere - WHERE platform='openstreetmap' - AND user_info->'username' = %s - """, (username,)) - if rec is not None: - user_info = rec - else: - osm_user = requests.get("%s/user/%s" % (osm_api_url, username)) - if osm_user.status_code == 200: - log("User %s found in OpenStreetMap but not in gittip." % username) - user_info = None - elif osm_user.status_code == 404: - raise Response(404, - "OpenStreetMap identity '{0}' not found.".format(username)) - else: - log("OpenStreetMap api responded with {0}: {1}".format(status, content), - level=logging.WARNING) - raise Response(502, "OpenStreetMap lookup failed with %d." % status) + # Platform attributes + name = 'openstreetmap' + display_name = 'OpenStreetMap' + account_url = 'http://www.openstreetmap.org/user/{user_name}' + icon = '/assets/icons/openstreetmap.12.png' - return user_info + # API attributes + api_format = 'xml' + api_user_info_path = '/user/{user_name}' + api_user_self_info_path = '/user/details' + x_user_id = xpath('./user', attr='id') + x_user_name = xpath('./user', attr='display_name') + x_display_name = x_user_name + x_email = not_available + x_avatar_url = xpath('./user/img', attr='href') diff --git a/gittip/elsewhere/twitter.py b/gittip/elsewhere/twitter.py index fc5c977811..b490075b4c 100644 --- a/gittip/elsewhere/twitter.py +++ b/gittip/elsewhere/twitter.py @@ -1,118 +1,35 @@ from __future__ import absolute_import, division, print_function, unicode_literals import datetime -from os import environ -from aspen import json, log, Response -from aspen.utils import to_age, utc -from gittip.elsewhere import PlatformOAuth1 -from gittip.models.account_elsewhere import AccountElsewhere import requests from requests_oauthlib import OAuth1 - -class TwitterAccount(AccountElsewhere): - - @property - def html_url(self): - return "https://twitter.com/" + self.user_info['screen_name'] - - @property - def display_name(self): - return self.user_info['screen_name'] - - def get_platform_icon(self): - return "/assets/icons/twitter.12.png" - - @property - def img_src(self): - src = '' - - # https://dev.twitter.com/docs/api/1.1/get/users/show - if 'profile_image_url_https' in self.user_info: - src = self.user_info['profile_image_url_https'] - - # For Twitter, we don't have good control over size. The - # biggest option is 73px(?!), but that's too small. Let's go - # with the original: even though it may be huge, that's - # preferrable to guaranteed blurriness. :-/ - - src = src.replace('_normal.', '.') - - return src +from aspen import log, Response +from aspen.utils import to_age, utc +from gittip.elsewhere import PlatformOAuth1, key, not_available class Twitter(PlatformOAuth1): + # Platform attributes name = 'twitter' - account_elsewhere_subclass = TwitterAccount - user_id_key= 'id' - username_key = 'screen_name' - api_url = environ['TWITTER_API_URL'] - - - def oauth_url(self, action, then=""): - """Return a URL to start oauth dancing with Twitter. - - For GitHub we can pass action and then through a querystring. For Twitter - we can't, so we send people through a local URL first where we stash this - info in an in-memory cache (eep! needs refactoring to scale). - """ - then = then.encode('base64').strip() - return "/on/twitter/redirect?action=%s&then=%s" % (action, then) - - - def get_user_info(self, screen_name, token=None, secret=None): - """ - """ - if token is None or secret is None: - token = environ['TWITTER_ACCESS_TOKEN'] - secret = environ['TWITTER_ACCESS_TOKEN_SECRET'] - # Updated using Twython as a point of reference: - # https://github.com/ryanmcgrath/twython/blob/master/twython/twython.py#L76 - oauth = OAuth1( - # we do not have access to the website obj, - # so let's grab the details from the env - environ['TWITTER_CONSUMER_KEY'], - environ['TWITTER_CONSUMER_SECRET'], - token, - secret, - ) - - url = "https://api.twitter.com/1.1/users/show.json?screen_name=%s" - user_info = requests.get(url % screen_name, auth=oauth) - - - # Keep an eye on our Twitter usage. - # ================================= - - rate_limit = user_info.headers['X-Rate-Limit-Limit'] - rate_limit_remaining = user_info.headers['X-Rate-Limit-Remaining'] - rate_limit_reset = user_info.headers['X-Rate-Limit-Reset'] - - try: - rate_limit = int(rate_limit) - rate_limit_remaining = int(rate_limit_remaining) - rate_limit_reset = int(rate_limit_reset) - except (TypeError, ValueError): - log( "Got weird rate headers from Twitter: %s %s %s" - % (rate_limit, rate_limit_remaining, rate_limit_reset) - ) - else: - reset = datetime.datetime.fromtimestamp(rate_limit_reset, tz=utc) - reset = to_age(reset) - log( "Twitter API calls used: %d / %d. Resets %s." - % (rate_limit - rate_limit_remaining, rate_limit, reset) - ) - - - if user_info.status_code == 200: - user_info = json.loads(user_info.text) - else: - log("Twitter lookup failed with %d." % user_info.status_code) - raise Response(404) - - # Add user page url. - user_info['html_url'] = "https://twitter.com/" + screen_name - - return user_info + display_name = 'Twitter' + account_url = 'https://twitter.com/{user_name}' + icon = '/assets/icons/twitter.12.png' + + # Auth attributes + auth_url = 'https://api.twitter.com' + + # API attributes + api_format = 'json' + api_url = 'https://api.twitter.com/1.1' + api_user_info_path = '/users/show.json?screen_name={user_name}' + api_user_self_info_path = '/account/verify_credentials.json' + ratelimit_headers_prefix = 'x-rate-limit-' + x_user_id = key('id') + x_user_name = key('screen_name') + x_display_name = key('name') + x_email = not_available + x_avatar_url = key('profile_image_url_https', + clean=lambda v: v.replace('_normal.', '.')) diff --git a/gittip/elsewhere/venmo.py b/gittip/elsewhere/venmo.py index 7fc44a3af7..3cf9a7f8a0 100644 --- a/gittip/elsewhere/venmo.py +++ b/gittip/elsewhere/venmo.py @@ -1,56 +1,34 @@ -from gittip.elsewhere import AccountElsewhere -from urllib import urlencode -from aspen import json, Response -import requests - - -class VenmoAccount(AccountElsewhere): - platform = u'venmo' - - def get_url(self): - return "https://venmo.com/" + self.user_info['username'] +from __future__ import absolute_import, division, print_function, unicode_literals - def get_profile_image(self): - return self.user_info['profile_picture_url'] - - def get_user_name(self): - return self.user_info['username'] - - def get_display_name(self): - return self.user_info['display_name'] +from urllib import urlencode - def get_platform_icon(self): - return "/assets/icons/venmo.16.png" +import requests -def oauth_url(website): - connect_params = { - 'client_id': website.venmo_client_id, - 'scope': 'make_payments', - 'redirect_uri': website.venmo_callback, - 'response_type': 'code' - } - url = u"https://api.venmo.com/v1/oauth/authorize?{}".format( - urlencode(connect_params) - ) - return url +from aspen import Response -def oauth_dance(website, qs): - """Return a dictionary of the Venmo response. +from gittip.elsewhere import PlatformOAuth2, key, not_available - There's an example at: https://developer.venmo.com/docs/authentication - """ - data = { - 'code': qs['code'].encode('US-ASCII'), - 'client_id': website.venmo_client_id, - 'client_secret': website.venmo_client_secret - } - r = requests.post('https://api.venmo.com/v1/oauth/access_token', data=data) - res_dict = r.json() +class Venmo(PlatformOAuth2): - if 'error' in res_dict: - raise Response(400, res_dict['error']['message'].encode('utf-8')) + # Platform attributes + name = 'venmo' + display_name = 'Venmo' + account_url = 'https://venmo.com/{user_name}' + icon = '/assets/icons/venmo.16.png' - assert r.status_code == 200, (r.status_code, r.text) + # PlatformOAuth2 attributes + auth_url = 'https://api.venmo.com/v1/oauth' + oauth_email_scope = 'access_email' + oauth_payment_scope = 'make_payments' - return res_dict + # API attributes + api_format = 'json' + api_url = 'https://api.venmo.com/v1' + api_user_info_path = '/users/{user_id}' + api_user_self_info_path = '/me' + x_user_id = key('id') + x_user_name = key('username') + x_display_name = key('display_name') + x_email = key('email') + x_avatar_url = key('profile_picture_url') diff --git a/gittip/exceptions.py b/gittip/exceptions.py index b6efae33f6..b69b11b207 100644 --- a/gittip/exceptions.py +++ b/gittip/exceptions.py @@ -5,9 +5,6 @@ from __future__ import print_function, unicode_literals - -class UnknownPlatform(Exception): pass - class ProblemChangingUsername(Exception): def __str__(self): return self.msg.format(self.args[0]) diff --git a/gittip/models/_mixin_elsewhere.py b/gittip/models/_mixin_elsewhere.py deleted file mode 100644 index 51c19d6aa5..0000000000 --- a/gittip/models/_mixin_elsewhere.py +++ /dev/null @@ -1,424 +0,0 @@ -import os -from collections import namedtuple - -from gittip import NotSane -from aspen.utils import typecheck -from psycopg2 import IntegrityError - -from gittip.exceptions import UnknownPlatform -from gittip.elsewhere import platform_classes -from gittip.utils.username import reserve_a_random_username, gen_random_usernames - - -# Exceptions -# ========== - -class NeedConfirmation(Exception): - """Represent the case where we need user confirmation during a merge. - - This is used in the workflow for merging one participant into another. - - """ - - def __init__(self, a, b, c): - self.other_is_a_real_participant = a - self.this_is_others_last_account_elsewhere = b - self.we_already_have_that_kind_of_account = c - self._all = (a, b, c) - - def __repr__(self): - return "" % self._all - __str__ = __repr__ - - def __eq__(self, other): - return self._all == other._all - - def __ne__(self, other): - return not self.__eq__(other) - - def __nonzero__(self): - # bool(need_confirmation) - A, B, C = self._all - return A or C - - -# Mixin -# ===== - -# note that the ordering of these fields is defined by platform_classes -AccountsTuple = namedtuple('AccountsTuple', platform_classes.keys()) - -class MixinElsewhere(object): - """We use this as a mixin for Participant, and in a hackish way on the - homepage and community pages. - - """ - - def get_accounts_elsewhere(self): - """Return an AccountsTuple of AccountElsewhere instances. - """ - - ACCOUNTS = "SELECT * FROM elsewhere WHERE participant=%s" - accounts = self.db.all(ACCOUNTS, (self.username,)) - - accounts_dict = {platform: None for platform in platform_classes} - - for account in accounts: - if account.platform not in platform_classes: - raise UnknownPlatform(account.platform) - - account_cls = platform_classes[account.platform] - accounts_dict[account.platform] = \ - account_cls(self.db, account.user_id, existing_record=account) - - return AccountsTuple(**accounts_dict) - - def get_img_src(self, size=128): - """Return a value for . - - Until we have our own profile pics, delegate. XXX Is this an attack - vector? Can someone inject this value? Don't think so, but if you make - it happen, let me know, eh? Thanks. :) - - https://www.gittip.com/security.txt - - """ - typecheck(size, int) - - src = '/assets/%s/avatar-default.gif' % os.environ['__VERSION__'] - - accounts = self.get_accounts_elsewhere() - - if accounts.github is not None: - # GitHub -> Gravatar: http://en.gravatar.com/site/implement/images/ - if 'gravatar_id' in accounts.github.user_info: - gravatar_hash = accounts.github.user_info['gravatar_id'] - src = "https://www.gravatar.com/avatar/%s.jpg?s=%s" - src %= (gravatar_hash, size) - - elif accounts.twitter is not None: - # https://dev.twitter.com/docs/api/1.1/get/users/show - if 'profile_image_url_https' in accounts.twitter.user_info: - src = accounts.twitter.user_info['profile_image_url_https'] - - # For Twitter, we don't have good control over size. The - # biggest option is 73px(?!), but that's too small. Let's go - # with the original: even though it may be huge, that's - # preferrable to guaranteed blurriness. :-/ - - src = src.replace('_normal.', '.') - - elif accounts.openstreetmap is not None: - if 'img_src' in accounts.openstreetmap.user_info: - src = accounts.openstreetmap.user_info['img_src'] - - return src - - - def take_over(self, account_elsewhere, have_confirmation=False): - """Given an AccountElsewhere and a bool, raise NeedConfirmation or return None. - - This method associates an account on another platform (GitHub, Twitter, - etc.) with the given Gittip participant. Every account elsewhere has an - associated Gittip participant account, even if its only a stub - participant (it allows us to track pledges to that account should they - ever decide to join Gittip). - - In certain circumstances, we want to present the user with a - confirmation before proceeding to reconnect the account elsewhere to - the new Gittip account; NeedConfirmation is the signal to request - confirmation. If it was the last account elsewhere connected to the old - Gittip account, then we absorb the old Gittip account into the new one, - effectively archiving the old account. - - Here's what absorbing means: - - - consolidated tips to and fro are set up for the new participant - - Amounts are summed, so if alice tips bob $1 and carl $1, and - then bob absorbs carl, then alice tips bob $2(!) and carl $0. - - And if bob tips alice $1 and carl tips alice $1, and then bob - absorbs carl, then bob tips alice $2(!) and carl tips alice $0. - - The ctime of each new consolidated tip is the older of the two - tips that are being consolidated. - - If alice tips bob $1, and alice absorbs bob, then alice tips - bob $0. - - If alice tips bob $1, and bob absorbs alice, then alice tips - bob $0. - - - all tips to and from the other participant are set to zero - - the absorbed username is released for reuse - - the absorption is recorded in an absorptions table - - This is done in one transaction. - - """ - - platform = account_elsewhere.platform - user_id = account_elsewhere.user_id - - CREATE_TEMP_TABLE_FOR_UNIQUE_TIPS = """ - - CREATE TEMP TABLE __temp_unique_tips ON COMMIT drop AS - - -- Get all the latest tips from everyone to everyone. - - SELECT DISTINCT ON (tipper, tippee) - ctime, tipper, tippee, amount - FROM tips - ORDER BY tipper, tippee, mtime DESC; - - """ - - CONSOLIDATE_TIPS_RECEIVING = """ - - -- Create a new set of tips, one for each current tip *to* either - -- the dead or the live account. If a user was tipping both the - -- dead and the live account, then we create one new combined tip - -- to the live account (via the GROUP BY and sum()). - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT min(ctime), tipper, %(live)s AS tippee, sum(amount) - - FROM __temp_unique_tips - - WHERE (tippee = %(dead)s OR tippee = %(live)s) - -- Include tips *to* either the dead or live account. - - AND NOT (tipper = %(dead)s OR tipper = %(live)s) - -- Don't include tips *from* the dead or live account, - -- lest we convert cross-tipping to self-tipping. - - AND amount > 0 - -- Don't include zeroed out tips, so we avoid a no-op - -- zero tip entry. - - GROUP BY tipper - - """ - - CONSOLIDATE_TIPS_GIVING = """ - - -- Create a new set of tips, one for each current tip *from* either - -- the dead or the live account. If both the dead and the live - -- account were tipping a given user, then we create one new - -- combined tip from the live account (via the GROUP BY and sum()). - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT min(ctime), %(live)s AS tipper, tippee, sum(amount) - - FROM __temp_unique_tips - - WHERE (tipper = %(dead)s OR tipper = %(live)s) - -- Include tips *from* either the dead or live account. - - AND NOT (tippee = %(dead)s OR tippee = %(live)s) - -- Don't include tips *to* the dead or live account, - -- lest we convert cross-tipping to self-tipping. - - AND amount > 0 - -- Don't include zeroed out tips, so we avoid a no-op - -- zero tip entry. - - GROUP BY tippee - - """ - - ZERO_OUT_OLD_TIPS_RECEIVING = """ - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT ctime, tipper, tippee, 0 AS amount - FROM __temp_unique_tips - WHERE tippee=%s AND amount > 0 - - """ - - ZERO_OUT_OLD_TIPS_GIVING = """ - - INSERT INTO tips (ctime, tipper, tippee, amount) - - SELECT ctime, tipper, tippee, 0 AS amount - FROM __temp_unique_tips - WHERE tipper=%s AND amount > 0 - - """ - - with self.db.get_cursor() as cursor: - - # Load the existing connection. - # ============================= - # Every account elsewhere has at least a stub participant account - # on Gittip. - - rec = cursor.one(""" - - SELECT participant - , claimed_time IS NULL AS is_stub - FROM elsewhere - JOIN participants ON participant=participants.username - WHERE elsewhere.platform=%s AND elsewhere.user_id=%s - - """, (platform, user_id), default=NotSane) - - other_username = rec.participant - - if self.username == other_username: - # this is a no op - trying to take over itself - return - - - # Make sure we have user confirmation if needed. - # ============================================== - # We need confirmation in whatever combination of the following - # three cases: - # - # - the other participant is not a stub; we are taking the - # account elsewhere away from another viable Gittip - # participant - # - # - the other participant has no other accounts elsewhere; taking - # away the account elsewhere will leave the other Gittip - # participant without any means of logging in, and it will be - # archived and its tips absorbed by us - # - # - we already have an account elsewhere connected from the given - # platform, and it will be handed off to a new stub - # participant - - # other_is_a_real_participant - other_is_a_real_participant = not rec.is_stub - - # this_is_others_last_account_elsewhere - nelsewhere = cursor.one( "SELECT count(*) FROM elsewhere " - "WHERE participant=%s" - , (other_username,) - ) - assert nelsewhere > 0 # sanity check - this_is_others_last_account_elsewhere = (nelsewhere == 1) - - # we_already_have_that_kind_of_account - nparticipants = cursor.one( "SELECT count(*) FROM elsewhere " - "WHERE participant=%s AND platform=%s" - , (self.username, platform) - ) - assert nparticipants in (0, 1) # sanity check - we_already_have_that_kind_of_account = nparticipants == 1 - - need_confirmation = NeedConfirmation( other_is_a_real_participant - , this_is_others_last_account_elsewhere - , we_already_have_that_kind_of_account - ) - if need_confirmation and not have_confirmation: - raise need_confirmation - - - # We have user confirmation. Proceed. - # =================================== - # There is a race condition here. The last person to call this will - # win. XXX: I'm not sure what will happen to the DB and UI for the - # loser. - - - # Move any old account out of the way. - # ==================================== - - if we_already_have_that_kind_of_account: - new_stub_username = reserve_a_random_username(cursor) - cursor.run( "UPDATE elsewhere SET participant=%s " - "WHERE platform=%s AND participant=%s" - , (new_stub_username, platform, self.username) - ) - - - # Do the deal. - # ============ - # If other_is_not_a_stub, then other will have the account - # elsewhere taken away from them with this call. If there are other - # browsing sessions open from that account, they will stay open - # until they expire (XXX Is that okay?) - - cursor.run( "UPDATE elsewhere SET participant=%s " - "WHERE platform=%s AND user_id=%s" - , (self.username, platform, user_id) - ) - - - # Fold the old participant into the new as appropriate. - # ===================================================== - # We want to do this whether or not other is a stub participant. - - if this_is_others_last_account_elsewhere: - - # Take over tips. - # =============== - - x, y = self.username, other_username - cursor.run(CREATE_TEMP_TABLE_FOR_UNIQUE_TIPS) - cursor.run(CONSOLIDATE_TIPS_RECEIVING, dict(live=x, dead=y)) - cursor.run(CONSOLIDATE_TIPS_GIVING, dict(live=x, dead=y)) - cursor.run(ZERO_OUT_OLD_TIPS_RECEIVING, (other_username,)) - cursor.run(ZERO_OUT_OLD_TIPS_GIVING, (other_username,)) - - - # Archive the old participant. - # ============================ - # We always give them a new, random username. We sign out - # the old participant. - - for archive_username in gen_random_usernames(): - try: - username = cursor.one(""" - - UPDATE participants - SET username=%s - , username_lower=%s - , session_token=NULL - , session_expires=now() - WHERE username=%s - RETURNING username - - """, ( archive_username - , archive_username.lower() - , other_username - ), default=NotSane) - except IntegrityError: - continue # archive_username is already taken; - # extremely unlikely, but ... - # XXX But can the UPDATE fail in other ways? - else: - assert username == archive_username - break - - - # Record the absorption. - # ====================== - # This is for preservation of history. - - cursor.run( "INSERT INTO absorptions " - "(absorbed_was, absorbed_by, archived_as) " - "VALUES (%s, %s, %s)" - , ( other_username - , self.username - , archive_username - ) - ) - -# Utter Hack -# ========== - -def utter_hack(db, records): - for rec in records: - yield UtterHack(db, rec) - -class UtterHack(MixinElsewhere): - def __init__(self, db, rec): - self.db = db - for name in rec._fields: - setattr(self, name, getattr(rec, name)) diff --git a/gittip/models/account_elsewhere.py b/gittip/models/account_elsewhere.py index d010065474..a92152174a 100644 --- a/gittip/models/account_elsewhere.py +++ b/gittip/models/account_elsewhere.py @@ -1,43 +1,30 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from gittip.models.participant import ProblemChangingUsername -from gittip.security.user import User from postgres.orm import Model - -class UnknownPlatform(Exception): - def __str__(self): - return "Unknown platform for account elsewhere: {}.".format(self.args[0]) +from gittip.exceptions import ProblemChangingUsername class AccountElsewhere(Model): typname = "elsewhere_with_participant" - subclasses = {} # populated in gittip.wireup.elsewhere - - - def __new__(cls, record): - platform = record['platform'] - cls = cls.subclasses.get(platform) - if cls is None: - raise UnknownPlatform(platform) - obj = super(AccountElsewhere, cls).__new__(cls, record) - return obj + def __init__(self, *args, **kwargs): + super(AccountElsewhere, self).__init__(*args, **kwargs) + self.platform_data = getattr(self.platforms, self.platform) - def set_is_locked(self, is_locked): - self.db.run(""" - - UPDATE elsewhere - SET is_locked=%s - WHERE platform=%s AND user_id=%s - - """, (is_locked, self.platform, self.user_id)) - + @property + def html_url(self): + return self.platform_data.account_url.format( + user_id=self.user_id, + user_name=self.user_name, + platform_data=self.platform_data + ) def opt_in(self, desired_username): """Given a desired username, return a User object. """ + from gittip.security.user import User self.set_is_locked(False) user = User.from_username(self.participant.username) user.sign_in() @@ -52,3 +39,12 @@ def opt_in(self, desired_username): except ProblemChangingUsername: pass return user, newly_claimed + + def set_is_locked(self, is_locked): + self.db.run(""" + + UPDATE elsewhere + SET is_locked=%s + WHERE platform=%s AND user_id=%s + + """, (is_locked, self.platform, self.user_id)) diff --git a/gittip/models/participant.py b/gittip/models/participant.py index cd16427814..3daab4a8e2 100644 --- a/gittip/models/participant.py +++ b/gittip/models/participant.py @@ -11,15 +11,17 @@ from __future__ import print_function, unicode_literals import datetime -import uuid from decimal import Decimal +import uuid -import gittip -import pytz from aspen import Response from aspen.utils import typecheck -from psycopg2 import IntegrityError from postgres.orm import Model +from psycopg2 import IntegrityError +import pytz + +import gittip +from gittip import NotSane from gittip.exceptions import ( UsernameIsEmpty, UsernameTooLong, @@ -29,11 +31,10 @@ NoSelfTipping, BadAmount, ) - -from gittip.models._mixin_elsewhere import MixinElsewhere from gittip.models._mixin_team import MixinTeam +from gittip.models.account_elsewhere import AccountElsewhere from gittip.utils import canonicalize -from gittip.utils.username import reserve_a_random_username +from gittip.utils.username import gen_random_usernames, reserve_a_random_username ASCII_ALLOWED_IN_USERNAME = set("0123456789" @@ -45,7 +46,7 @@ NANSWERS_THRESHOLD = 0 # configured in wireup.py -class Participant(Model, MixinElsewhere, MixinTeam): +class Participant(Model, MixinTeam): """Represent a Gittip participant. """ @@ -196,23 +197,14 @@ def recreate_api_key(self): def resolve_unclaimed(self): """Given a username, return an URL path. """ - rec = self.db.one( "SELECT platform, user_info " - "FROM elsewhere " - "WHERE participant = %s" + rec = self.db.one( "SELECT platform, user_name " + "FROM elsewhere " + "WHERE participant = %s" , (self.username,) ) if rec is None: - out = None - elif rec.platform == 'bitbucket': - out = '/on/bitbucket/%s/' % rec.user_info['username'] - elif rec.platform == 'github': - out = '/on/github/%s/' % rec.user_info['login'] - elif rec.platform == 'twitter': - out = '/on/twitter/%s/' % rec.user_info['screen_name'] - else: - assert rec.platform == 'openstreetmap' - out = '/on/openstreetmap/%s/' % rec.user_info['username'] - return out + return + return '/on/%s/%s/' % (rec.platform, rec.user_name) def set_as_claimed(self): claimed_time = self.db.one("""\ @@ -270,7 +262,7 @@ def insert_into_communities(self, is_member, name, slug): ) AS first_time_community - """, (username, slug, name, slug, username, is_member, username)) + """, (username, slug, name, slug, username, is_member, username)) def change_username(self, suggested): @@ -281,7 +273,6 @@ def change_username(self, suggested): """ # TODO: reconsider allowing unicode usernames - typecheck(suggested, unicode) suggested = suggested.strip() if not suggested: @@ -548,7 +539,7 @@ def get_giving_for_profile(self): , t.ctime , p.claimed_time , e.platform - , e.user_info + , e.user_name FROM tips t JOIN participants p ON p.username = t.tippee JOIN elsewhere e ON e.participant = t.tippee @@ -559,9 +550,7 @@ def get_giving_for_profile(self): , t.mtime DESC ) AS foo ORDER BY amount DESC - , lower(user_info->'screen_name') - , lower(user_info->'username') - , lower(user_info->'login') + , lower(user_name) """ unclaimed_tips = self.db.all(UNCLAIMED_TIPS, (self.username,)) @@ -699,6 +688,349 @@ def get_age_in_seconds(self): return out + def get_accounts_elsewhere(self): + """Return a dict of AccountElsewhere instances. + """ + accounts = self.db.all(""" + + SELECT elsewhere.*::elsewhere_with_participant + FROM elsewhere + WHERE participant=%s + + """, (self.username,)) + accounts_dict = {account.platform: account for account in accounts} + return accounts_dict + + + def take_over(self, account, have_confirmation=False): + """Given an AccountElsewhere or a tuple (platform_name, user_id), + associate an elsewhere account. + + Returns None or raises NeedConfirmation. + + This method associates an account on another platform (GitHub, Twitter, + etc.) with the given Gittip participant. Every account elsewhere has an + associated Gittip participant account, even if its only a stub + participant (it allows us to track pledges to that account should they + ever decide to join Gittip). + + In certain circumstances, we want to present the user with a + confirmation before proceeding to reconnect the account elsewhere to + the new Gittip account; NeedConfirmation is the signal to request + confirmation. If it was the last account elsewhere connected to the old + Gittip account, then we absorb the old Gittip account into the new one, + effectively archiving the old account. + + Here's what absorbing means: + + - consolidated tips to and fro are set up for the new participant + + Amounts are summed, so if alice tips bob $1 and carl $1, and + then bob absorbs carl, then alice tips bob $2(!) and carl $0. + + And if bob tips alice $1 and carl tips alice $1, and then bob + absorbs carl, then bob tips alice $2(!) and carl tips alice $0. + + The ctime of each new consolidated tip is the older of the two + tips that are being consolidated. + + If alice tips bob $1, and alice absorbs bob, then alice tips + bob $0. + + If alice tips bob $1, and bob absorbs alice, then alice tips + bob $0. + + - all tips to and from the other participant are set to zero + - the absorbed username is released for reuse + - the absorption is recorded in an absorptions table + + This is done in one transaction. + """ + + if isinstance(account, AccountElsewhere): + platform, user_id = account.platform, account.user_id + else: + platform, user_id = account + + CREATE_TEMP_TABLE_FOR_UNIQUE_TIPS = """ + + CREATE TEMP TABLE __temp_unique_tips ON COMMIT drop AS + + -- Get all the latest tips from everyone to everyone. + + SELECT DISTINCT ON (tipper, tippee) + ctime, tipper, tippee, amount + FROM tips + ORDER BY tipper, tippee, mtime DESC; + + """ + + CONSOLIDATE_TIPS_RECEIVING = """ + + -- Create a new set of tips, one for each current tip *to* either + -- the dead or the live account. If a user was tipping both the + -- dead and the live account, then we create one new combined tip + -- to the live account (via the GROUP BY and sum()). + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT min(ctime), tipper, %(live)s AS tippee, sum(amount) + + FROM __temp_unique_tips + + WHERE (tippee = %(dead)s OR tippee = %(live)s) + -- Include tips *to* either the dead or live account. + + AND NOT (tipper = %(dead)s OR tipper = %(live)s) + -- Don't include tips *from* the dead or live account, + -- lest we convert cross-tipping to self-tipping. + + AND amount > 0 + -- Don't include zeroed out tips, so we avoid a no-op + -- zero tip entry. + + GROUP BY tipper + + """ + + CONSOLIDATE_TIPS_GIVING = """ + + -- Create a new set of tips, one for each current tip *from* either + -- the dead or the live account. If both the dead and the live + -- account were tipping a given user, then we create one new + -- combined tip from the live account (via the GROUP BY and sum()). + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT min(ctime), %(live)s AS tipper, tippee, sum(amount) + + FROM __temp_unique_tips + + WHERE (tipper = %(dead)s OR tipper = %(live)s) + -- Include tips *from* either the dead or live account. + + AND NOT (tippee = %(dead)s OR tippee = %(live)s) + -- Don't include tips *to* the dead or live account, + -- lest we convert cross-tipping to self-tipping. + + AND amount > 0 + -- Don't include zeroed out tips, so we avoid a no-op + -- zero tip entry. + + GROUP BY tippee + + """ + + ZERO_OUT_OLD_TIPS_RECEIVING = """ + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT ctime, tipper, tippee, 0 AS amount + FROM __temp_unique_tips + WHERE tippee=%s AND amount > 0 + + """ + + ZERO_OUT_OLD_TIPS_GIVING = """ + + INSERT INTO tips (ctime, tipper, tippee, amount) + + SELECT ctime, tipper, tippee, 0 AS amount + FROM __temp_unique_tips + WHERE tipper=%s AND amount > 0 + + """ + + with self.db.get_cursor() as cursor: + + # Load the existing connection. + # ============================= + # Every account elsewhere has at least a stub participant account + # on Gittip. + + rec = cursor.one(""" + + SELECT participant + , claimed_time IS NULL AS is_stub + FROM elsewhere + JOIN participants ON participant=participants.username + WHERE elsewhere.platform=%s AND elsewhere.user_id=%s + + """, (platform, user_id), default=NotSane) + + other_username = rec.participant + + if self.username == other_username: + # this is a no op - trying to take over itself + return + + + # Make sure we have user confirmation if needed. + # ============================================== + # We need confirmation in whatever combination of the following + # three cases: + # + # - the other participant is not a stub; we are taking the + # account elsewhere away from another viable Gittip + # participant + # + # - the other participant has no other accounts elsewhere; taking + # away the account elsewhere will leave the other Gittip + # participant without any means of logging in, and it will be + # archived and its tips absorbed by us + # + # - we already have an account elsewhere connected from the given + # platform, and it will be handed off to a new stub + # participant + + # other_is_a_real_participant + other_is_a_real_participant = not rec.is_stub + + # this_is_others_last_account_elsewhere + nelsewhere = cursor.one( "SELECT count(*) FROM elsewhere " + "WHERE participant=%s" + , (other_username,) + ) + assert nelsewhere > 0 # sanity check + this_is_others_last_account_elsewhere = (nelsewhere == 1) + + # we_already_have_that_kind_of_account + nparticipants = cursor.one( "SELECT count(*) FROM elsewhere " + "WHERE participant=%s AND platform=%s" + , (self.username, platform) + ) + assert nparticipants in (0, 1) # sanity check + we_already_have_that_kind_of_account = nparticipants == 1 + + need_confirmation = NeedConfirmation( other_is_a_real_participant + , this_is_others_last_account_elsewhere + , we_already_have_that_kind_of_account + ) + if need_confirmation and not have_confirmation: + raise need_confirmation + + + # We have user confirmation. Proceed. + # =================================== + # There is a race condition here. The last person to call this will + # win. XXX: I'm not sure what will happen to the DB and UI for the + # loser. + + + # Move any old account out of the way. + # ==================================== + + if we_already_have_that_kind_of_account: + new_stub_username = reserve_a_random_username(cursor) + cursor.run( "UPDATE elsewhere SET participant=%s " + "WHERE platform=%s AND participant=%s" + , (new_stub_username, platform, self.username) + ) + + + # Do the deal. + # ============ + # If other_is_not_a_stub, then other will have the account + # elsewhere taken away from them with this call. If there are other + # browsing sessions open from that account, they will stay open + # until they expire (XXX Is that okay?) + + cursor.run( "UPDATE elsewhere SET participant=%s " + "WHERE platform=%s AND user_id=%s" + , (self.username, platform, user_id) + ) + + + # Fold the old participant into the new as appropriate. + # ===================================================== + # We want to do this whether or not other is a stub participant. + + if this_is_others_last_account_elsewhere: + + # Take over tips. + # =============== + + x, y = self.username, other_username + cursor.run(CREATE_TEMP_TABLE_FOR_UNIQUE_TIPS) + cursor.run(CONSOLIDATE_TIPS_RECEIVING, dict(live=x, dead=y)) + cursor.run(CONSOLIDATE_TIPS_GIVING, dict(live=x, dead=y)) + cursor.run(ZERO_OUT_OLD_TIPS_RECEIVING, (other_username,)) + cursor.run(ZERO_OUT_OLD_TIPS_GIVING, (other_username,)) + + + # Archive the old participant. + # ============================ + # We always give them a new, random username. We sign out + # the old participant. + + for archive_username in gen_random_usernames(): + try: + username = cursor.one(""" + + UPDATE participants + SET username=%s + , username_lower=%s + , session_token=NULL + , session_expires=now() + WHERE username=%s + RETURNING username + + """, ( archive_username + , archive_username.lower() + , other_username + ), default=NotSane) + except IntegrityError: + continue # archive_username is already taken; + # extremely unlikely, but ... + # XXX But can the UPDATE fail in other ways? + else: + assert username == archive_username + break + + + # Record the absorption. + # ====================== + # This is for preservation of history. + + cursor.run( "INSERT INTO absorptions " + "(absorbed_was, absorbed_by, archived_as) " + "VALUES (%s, %s, %s)" + , ( other_username + , self.username + , archive_username + ) + ) + + +class NeedConfirmation(Exception): + """Represent the case where we need user confirmation during a merge. + + This is used in the workflow for merging one participant into another. + + """ + + def __init__(self, a, b, c): + self.other_is_a_real_participant = a + self.this_is_others_last_account_elsewhere = b + self.we_already_have_that_kind_of_account = c + self._all = (a, b, c) + + def __repr__(self): + return "" % self._all + __str__ = __repr__ + + def __eq__(self, other): + return self._all == other._all + + def __ne__(self, other): + return not self.__eq__(other) + + def __nonzero__(self): + # bool(need_confirmation) + A, B, C = self._all + return A or C + + def typecast(request): """Given a Request, raise Response or return Participant. diff --git a/gittip/testing/__init__.py b/gittip/testing/__init__.py index d1f1d7b3e0..bde2da63bd 100644 --- a/gittip/testing/__init__.py +++ b/gittip/testing/__init__.py @@ -129,15 +129,11 @@ def clear_tables(self): tablenames.insert(0, tablename) - def make_elsewhere(self, platform, user_id, user_info=None): - platform = self.platforms[platform] - if user_info is None: - user_info = {} - if platform.user_id_key not in user_info: - user_info[platform.user_id_key] = user_id - if platform.username_key not in user_info: - user_info[platform.username_key] = user_id - return platform.upsert(user_id, user_info) + def make_elsewhere(self, platform, user_id, user_name, display_name=None, + email=None, avatar_url=None, extra_info=None): + platform = getattr(self.platforms, platform) + return platform.upsert(unicode(user_id), user_name, display_name, email, + avatar_url, extra_info) def show_table(self, table): @@ -164,17 +160,16 @@ def make_participant(self, username, **kw): participant = Participant.with_random_username() participant.change_username(username) - return self.update_participant(participant, **kw) - - def update_participant(self, participant, **kw): if 'elsewhere' in kw or 'claimed_time' in kw: username = participant.username platform = kw.pop('elsewhere', 'github') - user_info = dict(login=username) self.seq += 1 - self.db.run("INSERT INTO elsewhere (platform, user_id, participant, user_info) " - "VALUES (%s,%s,%s,%s)", (platform, self.seq, username, user_info)) + self.db.run(""" + INSERT INTO elsewhere + (platform, user_id, user_name, participant) + VALUES (%s,%s,%s,%s) + """, (platform, self.seq, username, username)) # brute force update for use in testing for k,v in kw.items(): diff --git a/gittip/utils/__init__.py b/gittip/utils/__init__.py index a6d2eef5ad..a554389f5b 100644 --- a/gittip/utils/__init__.py +++ b/gittip/utils/__init__.py @@ -365,8 +365,8 @@ def update_homepage_queries_once(db): cursor.execute("DELETE FROM homepage_top_givers") cursor.execute(""" - INSERT INTO homepage_top_givers (username, anonymous, amount) - SELECT tipper, anonymous_giving, sum(amount) AS amount + INSERT INTO homepage_top_givers (username, anonymous, amount, avatar_url) + SELECT tipper, anonymous_giving, sum(amount) AS amount, avatar_url FROM ( SELECT DISTINCT ON (tipper, tippee) amount , tipper @@ -382,34 +382,17 @@ def update_homepage_queries_once(db): ) AS foo JOIN participants p ON p.username = tipper WHERE is_suspicious IS NOT true - GROUP BY tipper, anonymous_giving - ORDER BY amount DESC; + GROUP BY tipper, anonymous_giving, avatar_url + ORDER BY amount DESC + LIMIT 100; """.strip()) - cursor.execute(""" - - UPDATE homepage_top_givers - SET gravatar_id = ( SELECT user_info->'gravatar_id' - FROM elsewhere - WHERE participant=username - AND platform='github' - ) - """) - cursor.execute(""" - - UPDATE homepage_top_givers - SET twitter_pic = ( SELECT user_info->'profile_image_url_https' - FROM elsewhere - WHERE participant=username - AND platform='twitter' - ) - """) cursor.execute("DELETE FROM homepage_top_receivers") cursor.execute(""" - INSERT INTO homepage_top_receivers (username, anonymous, amount, claimed_time) - SELECT tippee, anonymous_receiving, sum(amount) AS amount, claimed_time + INSERT INTO homepage_top_receivers (username, anonymous, amount, avatar_url) + SELECT tippee, anonymous_receiving, sum(amount) AS amount, avatar_url FROM ( SELECT DISTINCT ON (tipper, tippee) amount , tippee @@ -424,28 +407,12 @@ def update_homepage_queries_once(db): ) AS foo JOIN participants p ON p.username = tippee WHERE is_suspicious IS NOT true - GROUP BY tippee, anonymous_receiving, claimed_time - ORDER BY amount DESC; + GROUP BY tippee, anonymous_receiving, avatar_url + ORDER BY amount DESC + LIMIT 100; """.strip()) - cursor.execute(""" - UPDATE homepage_top_receivers - SET gravatar_id = ( SELECT user_info->'gravatar_id' - FROM elsewhere - WHERE participant=username - AND platform='github' - ) - """) - cursor.execute(""" - - UPDATE homepage_top_receivers - SET twitter_pic = ( SELECT user_info->'profile_image_url_https' - FROM elsewhere - WHERE participant=username - AND platform='twitter' - ) - """) end = time.time() elapsed = end - start log_dammit("updated homepage queries in %.2f seconds" % elapsed) @@ -465,11 +432,3 @@ def wrapper(*a, **kw): del SimpleCursorBase.execute return ret return wrapper - -def redirect_confirmation(website, request): - from aspen import resources - request.internally_redirected_from = request.fs - request.fs = website.www_root + '/on/confirm.html.spt' - request.resource = resources.get(request) - - raise request.resource.respond(request) diff --git a/gittip/utils/fake_data.py b/gittip/utils/fake_data.py index 299698c3a4..b632cf3a45 100644 --- a/gittip/utils/fake_data.py +++ b/gittip/utils/fake_data.py @@ -1,17 +1,16 @@ from faker import Factory from gittip import wireup, MAX_TIP, MIN_TIP +from gittip.elsewhere import PLATFORMS from gittip.models.participant import Participant +import datetime import decimal import random import string -import datetime faker = Factory.create() -platforms = ['github', 'twitter', 'bitbucket', 'openstreetmap'] - def _fake_thing(db, tablename, **kw): column_names = [] @@ -108,46 +107,21 @@ def fake_tip(db, tipper, tippee): ) -def fake_elsewhere(db, participant, platform=None): +def fake_elsewhere(db, participant, platform): """Create a fake elsewhere. """ - if platform is None: - platform = random.choice(platforms) - - info_templates = { - "github": { - "name": participant.username, - "html_url": "https://github.com/" + participant.username, - "type": "User", - "login": participant.username - }, - "twitter": { - "name": participant.username, - "html_url": "https://twitter.com/" + participant.username, - "screen_name": participant.username - }, - "bitbucket": { - "display_name": participant.username, - "username": participant.username, - "is_team": "False", - "html_url": "https://bitbucket.org/" + participant.username, - }, - "openstreetmap": { - "username": participant.username, - "html_url": "https://openstreetmap/user/" + participant.username, - } - } - _fake_thing( db , "elsewhere" , id=fake_int_id() , platform=platform , user_id=fake_text_id() + , user_name=participant.username , is_locked=False , participant=participant.username - , user_info=info_templates[platform] + , extra_info=None ) + def fake_transfer(db, tipper, tippee): return _fake_thing( db , "transfers" @@ -171,7 +145,7 @@ def populate_db(db, num_participants=100, num_tips=200, num_teams=5, num_transfe for p in participants: #All participants get between 1 and 3 elsewheres num_elsewheres = random.randint(1, 3) - for platform_name in platforms[:num_elsewheres]: + for platform_name in random.sample(PLATFORMS, num_elsewheres): fake_elsewhere(db, p, platform_name) #Make teams diff --git a/gittip/wireup.py b/gittip/wireup.py index 4cb757c119..2a978a444b 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -12,8 +12,11 @@ import stripe from gittip.elsewhere import PlatformRegistry from gittip.elsewhere.bitbucket import Bitbucket +from gittip.elsewhere.bountysource import Bountysource from gittip.elsewhere.github import GitHub +from gittip.elsewhere.openstreetmap import OpenStreetMap from gittip.elsewhere.twitter import Twitter +from gittip.elsewhere.venmo import Venmo from gittip.models.account_elsewhere import AccountElsewhere from gittip.models.community import Community from gittip.models.participant import Participant @@ -30,10 +33,6 @@ def db(): maxconn = int(os.environ['DATABASE_MAXCONN']) db = GittipDB(dburl, maxconn=maxconn) - # register hstore type - with db.get_cursor() as cursor: - psycopg2.extras.register_hstore(cursor, globally=True, unicode=True) - db.register_model(Community) db.register_model(AccountElsewhere) db.register_model(Participant) @@ -170,33 +169,53 @@ def envvar(key, cast=None): def is_yesish(val): return val.lower() in ('1', 'true', 'yes') - website.bitbucket_consumer_key = envvar('BITBUCKET_CONSUMER_KEY') - website.bitbucket_consumer_secret = envvar('BITBUCKET_CONSUMER_SECRET') - website.bitbucket_callback = envvar('BITBUCKET_CALLBACK') - - website.github_client_id = envvar('GITHUB_CLIENT_ID') - website.github_client_secret = envvar('GITHUB_CLIENT_SECRET') - website.github_callback = envvar('GITHUB_CALLBACK') - - website.twitter_consumer_key = envvar('TWITTER_CONSUMER_KEY') - website.twitter_consumer_secret = envvar('TWITTER_CONSUMER_SECRET') - website.twitter_access_token = envvar('TWITTER_ACCESS_TOKEN') - website.twitter_access_token_secret = envvar('TWITTER_ACCESS_TOKEN_SECRET') - website.twitter_callback = envvar('TWITTER_CALLBACK') - - website.bountysource_www_host = envvar('BOUNTYSOURCE_WWW_HOST') - website.bountysource_api_host = envvar('BOUNTYSOURCE_API_HOST') - website.bountysource_api_secret = envvar('BOUNTYSOURCE_API_SECRET') - website.bountysource_callback = envvar('BOUNTYSOURCE_CALLBACK') - - website.venmo_client_id = envvar('VENMO_CLIENT_ID') - website.venmo_client_secret = envvar('VENMO_CLIENT_SECRET') - website.venmo_callback = envvar('VENMO_CALLBACK') - - website.openstreetmap_api = envvar('OPENSTREETMAP_API') - website.openstreetmap_consumer_key = envvar('OPENSTREETMAP_CONSUMER_KEY') - website.openstreetmap_consumer_secret = envvar('OPENSTREETMAP_CONSUMER_SECRET') - website.openstreetmap_callback = envvar('OPENSTREETMAP_CALLBACK') + signin_platforms = [ + Twitter( + website.db, + envvar('TWITTER_CONSUMER_KEY'), + envvar('TWITTER_CONSUMER_SECRET'), + envvar('TWITTER_CALLBACK'), + ), + GitHub( + website.db, + envvar('GITHUB_CLIENT_ID'), + envvar('GITHUB_CLIENT_SECRET'), + envvar('GITHUB_CALLBACK'), + ), + Bitbucket( + website.db, + envvar('BITBUCKET_CONSUMER_KEY'), + envvar('BITBUCKET_CONSUMER_SECRET'), + envvar('BITBUCKET_CALLBACK'), + ), + OpenStreetMap( + website.db, + envvar('OPENSTREETMAP_CONSUMER_KEY'), + envvar('OPENSTREETMAP_CONSUMER_SECRET'), + envvar('OPENSTREETMAP_CALLBACK'), + envvar('OPENSTREETMAP_API_URL'), + envvar('OPENSTREETMAP_AUTH_URL'), + ), + ] + website.signin_platforms = signin_platforms + other_platforms = [ + Bountysource( + website.db, + None, + envvar('BOUNTYSOURCE_API_SECRET'), + envvar('BOUNTYSOURCE_CALLBACK'), + envvar('BOUNTYSOURCE_API_HOST'), + envvar('BOUNTYSOURCE_WWW_HOST'), + ), + Venmo( + website.db, + envvar('VENMO_CLIENT_ID'), + envvar('VENMO_CLIENT_SECRET'), + envvar('VENMO_CALLBACK'), + ), + ] + platforms = signin_platforms + other_platforms + website.platforms = AccountElsewhere.platforms = PlatformRegistry(platforms) website.asset_version_url = envvar('GITTIP_ASSET_VERSION_URL') \ .replace('%version', website.version) @@ -253,8 +272,3 @@ def is_yesish(val): aspen.log_dammit("=" * 42) keys = ', '.join([key for key in missing_keys]) raise BadEnvironment("Missing envvar{}: {}.".format(plural, keys)) - - -def platforms(website): - website.platforms = PlatformRegistry(website.db) - website.platforms.register(Bitbucket, GitHub, Twitter) diff --git a/requirements.txt b/requirements.txt index 9c6dc207c8..a95acc8010 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,8 @@ ./vendor/chardet-1.0.1.tar.gz ./vendor/oauthlib-0.4.2.tar.gz ./vendor/requests-1.2.3.tar.gz -./vendor/requests-oauthlib-0.3.2.tar.gz +./vendor/requests-oauthlib-0.4.0.tar.gz +./vendor/xmltodict-0.8.4.tar.gz ./vendor/stripe-1.9.1.tar.gz diff --git a/scss/buttons-knobs.scss b/scss/buttons-knobs.scss index cea32036f5..4908679996 100644 --- a/scss/buttons-knobs.scss +++ b/scss/buttons-knobs.scss @@ -58,3 +58,16 @@ button.join-leave[data-is-member="true"] { button.join-leave[data-is-member="false"] { @extend .selected; } + +form.auth-button { + display: inline-block; + & > button { + margin: 0; + padding: 0; + color: $green; + background: none; + border: none; + font: inherit; + font-weight: bold; + } +} diff --git a/scss/lib/_dropdown.scss b/scss/lib/_dropdown.scss index 65857f00a1..454d6144cc 100644 --- a/scss/lib/_dropdown.scss +++ b/scss/lib/_dropdown.scss @@ -21,7 +21,7 @@ .open .dropdown-toggle { outline: 0; } -a.dropdown-toggle{ +button.dropdown-toggle{ width: 75px; text-align: center; } @@ -68,7 +68,7 @@ a.dropdown-toggle{ background-color: #e5e5e5; border-bottom: 1px solid #ffffff; } -.dropdown-menu > li > a { +.dropdown-menu > li button { display: block; padding: 3px 20px; clear: both; @@ -77,10 +77,10 @@ a.dropdown-toggle{ color: #333333; white-space: nowrap; } -.dropdown-menu > li > a:hover, -.dropdown-menu > li > a:focus, -.dropdown-submenu:hover > a, -.dropdown-submenu:focus > a { +.dropdown-menu > li button:hover, +.dropdown-menu > li button:focus, +.dropdown-submenu:hover button, +.dropdown-submenu:focus button { text-decoration: none; color: #ffffff; background-color: #0081c2; @@ -92,9 +92,9 @@ a.dropdown-toggle{ background-repeat: repeat-x; filter: unquote("progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0)"); } -.dropdown-menu > .active > a, -.dropdown-menu > .active > a:hover, -.dropdown-menu > .active > a:focus { +.dropdown-menu > .active button, +.dropdown-menu > .active button:hover, +.dropdown-menu > .active button:focus { color: #ffffff; text-decoration: none; outline: 0; @@ -107,13 +107,13 @@ a.dropdown-toggle{ background-repeat: repeat-x; filter: unquote("progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0)"); } -.dropdown-menu > .disabled > a, -.dropdown-menu > .disabled > a:hover, -.dropdown-menu > .disabled > a:focus { +.dropdown-menu > .disabled button, +.dropdown-menu > .disabled button:hover, +.dropdown-menu > .disabled button:focus { color: #999999; } -.dropdown-menu > .disabled > a:hover, -.dropdown-menu > .disabled > a:focus { +.dropdown-menu > .disabled button:hover, +.dropdown-menu > .disabled button:focus { text-decoration: none; background-color: transparent; background-image: none; @@ -167,7 +167,7 @@ a.dropdown-toggle{ margin-bottom: -2px; border-radius: 5px 5px 5px 0; } -.dropdown-submenu > a:after { +.dropdown-submenu button:after { display: block; content: " "; float: right; @@ -180,7 +180,7 @@ a.dropdown-toggle{ margin-top: 5px; margin-right: -10px; } -.dropdown-submenu:hover > a:after { +.dropdown-submenu:hover button:after { border-left-color: #ffffff; } .dropdown-submenu.pull-left { diff --git a/scss/widgets/_sign_in.scss b/scss/widgets/_sign_in.scss index ebc0e023a1..9c067d53e0 100644 --- a/scss/widgets/_sign_in.scss +++ b/scss/widgets/_sign_in.scss @@ -31,28 +31,44 @@ min-width: 120px; li { margin: 0; - &.twitter a { + &.twitter button { @include has-icon("twitter"); } - &.github a { + &.github button { @include has-icon("github"); } - &.bitbucket a { + &.bitbucket button { @include has-icon("bitbucket"); } - &.openstreetmap a { + &.openstreetmap button { @include has-icon("openstreetmap"); } + &:hover button { + background: $darker-green; + } } - a { + button { color: #fff; + display: block; + width: 100%; padding: 3px 8px; + margin: 0; text-align: left; position: relative; font-size: 12px; - &:hover { - background: $darker-green; - } + border: none; + @include border-radius(0); + background: none; + } + button:before { + position: absolute; + top: 4px; + right: 5px; + } + form { + display: block; + margin: 0; + padding: 0; } } #header & { @@ -61,10 +77,5 @@ left: auto; right: 0; } - a:before { - position: absolute; - top: 4px; - right: 5px; - } } } diff --git a/templates/auth.html b/templates/auth.html new file mode 100644 index 0000000000..f82bc98b4c --- /dev/null +++ b/templates/auth.html @@ -0,0 +1,9 @@ +{% macro auth_button(platform, action, user_name='') %} +
+ + + + + +
+{% endmacro %} diff --git a/templates/connected-accounts.html b/templates/connected-accounts.html index 007bb07fb2..f074478901 100644 --- a/templates/connected-accounts.html +++ b/templates/connected-accounts.html @@ -1,142 +1,35 @@ +{% from 'templates/auth.html' import auth_button with context %} +

Connected Accounts

+{% for platform in website.platforms %} + {% set account = accounts.get(platform.name, None) %} - - - - - - - - - - - - - - - - - - - - +{% endfor %}