forked from gratipay/gratipay.com
/
__init__.py
236 lines (181 loc) · 7.95 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""This subpackage contains functionality for working with accounts elsewhere.
"""
from __future__ import print_function, unicode_literals
from collections import OrderedDict
from aspen.utils import typecheck
from aspen import json
from psycopg2 import IntegrityError
import gittip
from gittip.exceptions import ProblemChangingUsername, UnknownPlatform
from gittip.utils.username import reserve_a_random_username
ACTIONS = [u'opt-in', u'connect', u'lock', u'unlock']
# to add a new elsewhere/platform:
# 1) add its name (also the name of its module) to this list.
# it's best to append it; this ordering is used in templates.
# 2) inherit from AccountElsewhere in the platform class
#
# platform_modules will populate the platform class automatically in configure-aspen.
platforms_ordered = (
'twitter',
'github',
'bitbucket',
'bountysource',
'venmo',
'openstreetmap'
)
# init-time key setup ensures the future ordering of platform_classes will match
# platforms_ordered, since overwriting entries will maintain their order.
platform_classes = OrderedDict([(platform, None) for platform in platforms_ordered])
class _RegisterPlatformMeta(type):
"""Tied to AccountElsewhere to enable registration by the platform field.
"""
def __new__(cls, name, bases, dct):
c = super(_RegisterPlatformMeta, cls).__new__(cls, name, bases, dct)
# * register the platform
# * verify it was added at init-time
# * register the subclass's json encoder with aspen
c_platform = getattr(c, 'platform')
if name == 'AccountElsewhere':
pass
elif c_platform not in platform_classes:
raise UnknownPlatform(c_platform) # has it been added to platform_classes init?
else:
platform_classes[c_platform] = c
# aspen's json encoder registry does not take class hierarchies into account,
# so we need to register the subclasses explicitly.
json.register_encoder(c, c.to_json_compatible_object)
return c
class AccountElsewhere(object):
__metaclass__ = _RegisterPlatformMeta
platform = None # set in subclass
# only fields in this set will be encoded
json_encode_field_whitelist = set([
'id', 'is_locked', 'participant', 'platform', 'user_id', 'user_info',
])
def __init__(self, db, user_id, user_info=None, existing_record=None):
"""Either:
- Takes a user_id and user_info, and updates the database.
Or:
- Takes a user_id and existing_record, and constructs a "model" object out of the record
"""
typecheck(user_id, (int, unicode), user_info, (None, dict))
self.user_id = unicode(user_id)
self.db = db
if user_info is not None:
a,b,c,d = self.upsert(user_info)
self.participant = a
self.is_claimed = b
self.is_locked = c
self.balance = d
self.user_info = user_info
# hack to make this into a weird pseudo-model that can share convenience methods
elif existing_record is not None:
self.participant = existing_record.participant
self.is_claimed, self.is_locked, self.balance = self.get_misc_info(self.participant)
self.user_info = existing_record.user_info
self.record = existing_record
def to_json_compatible_object(self):
"""
This is registered as an aspen.json encoder in configure-aspen
for all subclasses of this class.
It only exports fields in the whitelist.
"""
output = {k: v for (k,v) in self.record._asdict().items()
if k in self.json_encode_field_whitelist}
return output
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.
"""
from gittip.security.user import User
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)
# 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:
with self.db.get_cursor() as cursor:
_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)
)
except IntegrityError:
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 participant
""", (user_info, self.platform, self.user_id))
return (username,) + self.get_misc_info(username)
def get_misc_info(self, username):
rec = self.db.one("""
SELECT claimed_time, balance, is_locked
FROM participants
JOIN elsewhere
ON participants.username=participant
WHERE platform=%s
AND participants.username=%s
""", (self.platform, username))
assert rec is not None # sanity check
return ( rec.claimed_time is not None
, rec.is_locked
, rec.balance
)
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))