This repository has been archived by the owner on Feb 25, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 30
/
users.py
368 lines (288 loc) · 13.6 KB
/
users.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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
#!/usr/bin/python
# Copyright 2013 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy
# of the License at: http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software distrib-
# uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
# specific language governing permissions and limitations under the License.
"""User accounts and preferences.
All access to user information should go through this module's API.
google.appengine.api.users should not be used anywhere outside this file.
"""
__author__ = '[email protected] (Ka-Ping Yee)'
import datetime
import os
import urllib
import config
import utils
from google.appengine.api import users as gae_users
from google.appengine.ext import ndb
# Number of accounts to fetch at a time
_ACCOUNTS_FETCH_SIZE = 100
class _GoogleAccount(ndb.Model):
"""A mapping from a Google Account to a UserModel entity.
This entity's .key.id() is the user_id() of a google.appengine.api.users.User.
"""
# uid is the .key.id() of the _UserModel corresponding to this Google Account.
uid = ndb.StringProperty()
@classmethod
def _get_kind(cls): # pylint: disable=g-bad-name
return 'GoogleAccount' # so we can name the Python class with an underscore
class _UserModel(ndb.Model):
"""Private entity for storing user profiles.
This entity's key.id() is exposed as the .id attribute on User; see the User
class below for its definition.
"""
# The time this UserModel was first created.
created = ndb.DateTimeProperty()
# True if this user has ever signed in.
active = ndb.BooleanProperty(default=False)
# The last seen Google Account domain for the user (i.e. USER_ORGANIZATION).
# See https://developers.google.com/appengine/articles/auth for details.
# Note that this is not a simple function of the e-mail address -- it can be
# present or not present for various e-mail addresses with the same domain.
ga_domain = ndb.StringProperty(default='')
# The last known e-mail address for the user. This can change over time.
email = ndb.StringProperty(default='')
# True if the user has given consent to receive marketing e-mail.
marketing_consent = ndb.BooleanProperty(default=False)
# True if the user has submitted an answer to the marketing consent question.
marketing_consent_answered = ndb.BooleanProperty(default=False)
# True if the user has selected "Don't show again" for the welcome message.
welcome_message_dismissed = ndb.BooleanProperty(default=False)
@classmethod
def _get_kind(cls): # pylint: disable=g-bad-name
return 'UserModel' # so we can name the Python class with an underscore
class User(utils.Struct):
"""Public class for user profiles.
For all real users (i.e. with Google Accounts), the .id attribute is the
string decimal representation of a sequentially chosen positive integer.
For other accounts used in testing and development, the .id can be any string
containing a non-digit. By convention, a variable named 'user' usually holds
a User object, and a variable named 'uid' holds the .id of a User object.
"""
def __repr__(self):
return 'User(id=%r, ga_domain=%r, email=%r)' % (
self.id, self.ga_domain, self.email)
# This is not an old-style class. # pylint: disable=property-on-old-class
email_username = property(lambda self: self.email.split('@')[0])
email_domain = property(lambda self: self.email.split('@')[-1])
# pylint: enable=property-on-old-class
def _GetModel(uid):
"""Gets a _UserModel entity by uid, or raises KeyError."""
model = _UserModel.get_by_id(uid)
if not model:
raise KeyError('No UserModel exists with ID %r' % uid)
return model
@ndb.transactional
def _GenerateNextUid():
"""Generates a sequentially increasing string uid, starting with '1'.
It is important that the uids remain ever increasing, since users are
occassionally deleted. That potentially leaves their former uid still
in-place in various models; we do not want that uid associated with a
new, different user.
Returns:
The newly-generated uid.
"""
class Counter(ndb.Model):
last_used = ndb.IntegerProperty()
counter = Counter.get_by_id('uid') or Counter(id='uid', last_used=0)
counter.last_used += 1
counter.put()
return str(counter.last_used)
def _EmailToGaeUserId(email):
"""Gets the Google Account user IDs for the given e-mail addresses."""
# Different address strings can map to the same ID (e.g. '[email protected]'
# and '[email protected]'), so it's best to ask App Engine to do this mapping.
# Stupidly, App Engine has no simple API for doing this; the only way to
# achieve this conversion is to store a User property and fetch it back.
class DummyUser(ndb.Model):
user = ndb.UserProperty()
# The UserProperty's user_id() is magically populated when it is written to
# the datastore. We have to turn off caching to get it, though; with caching,
# get() just returns the original in-memory entity, which has no user_id().
key = DummyUser(user=gae_users.User(email)).put(use_cache=False)
gae_user_id = key.get(use_cache=False).user.user_id()
key.delete()
return gae_user_id
def _GetLoginInfo():
"""Gets the effective uid, GA domain, and e-mail address of the current user.
The effective user is normally determined by the Google Account login state.
Note that uid is an application-local user ID, not the Google Account ID.
If the app is running in development or accessed by an App Engine app admin,
the crisismap_login cookie can be used to impersonate any login.
Returns:
Three strings (uid, ga_domain, email), or ('', '', '') if not signed in.
"""
# os.environ is safe to read on a multithreaded server, as it's thread-local
# in the Python 2.7 runtime (see http://goo.gl/VmGRa, http://goo.gl/wwcNN, or
# the implementation at google/appengine/runtime/request_environment.py).
header = os.environ.get('HTTP_COOKIE', '')
if header and IsDeveloper():
# The crisismap_login cookie translates directly to a UserModel, with
# no GoogleAccount mapping involved.
cookies = dict(pair.strip().split('=', 1) for pair in header.split(';'))
login_parts = cookies.get('crisismap_login', '').split(':')
if len(login_parts) == 3:
return tuple(login_parts) # valid cookie format is "uid:ga_domain:email"
gae_user = gae_users.get_current_user() # a google.appengine.api.users.User
if gae_user and gae_user.user_id():
ga_domain, email = os.environ.get('USER_ORGANIZATION', ''), gae_user.email()
# If user has signed in before, we have a mapping to an existing UserModel.
ga = _GoogleAccount.get_by_id(gae_user.user_id())
if ga:
return ga.uid, ga_domain, email
else:
# This user has never signed in before *and* GetForEmail() has never been
# called with an e-mail address that was, at the time, associated with
# this Google Account. Associate this Google Account with the UserModel
# that has a matching e-mail address, or make a new UserModel.
model = _UserModel.query(_UserModel.email == email).get()
# NOTE(kpy): The above might miss a UserModel that should be associated
# with this user: if the UserModel was created by calling GetForEmail()
# with an e-mail address that Google Accounts considers the same, but
# differs (e.g. in capitalization) from the Google Account's canonical
# address, the .email property won't match. If we really want to handle
# this, we could try _EmailToGaeUserId on all the inactive UserModels.
uid = model and model.key.id() or _GenerateNextUid()
_GoogleAccount(id=gae_user.user_id(), uid=uid).put()
return uid, ga_domain, email
return '', '', ''
def IsDeveloper():
"""Returns True if running in development or the user is an app admin."""
return utils.IsDevelopmentServer() or gae_users.is_current_user_admin()
def Get(uid):
"""Returns the User object for a given uid, or None if no such user."""
try:
return User.FromModel(_GetModel(uid))
except KeyError:
return None
def Delete(uid):
"""Deletes the UserModel and GoogleAccount objects for a given uid."""
_GetModel(uid).key.delete()
for google_account in _GoogleAccount.query(_GoogleAccount.uid == uid):
google_account.key.delete()
def GetCurrent():
"""Returns the User object for the effective signed-in user, or None."""
uid, ga_domain, email = _GetLoginInfo()
if uid:
# The GA domain and e-mail address associated with an account can change;
# update or create the UserModel entity as needed.
model = (_UserModel.get_by_id(uid) or
_UserModel(id=uid, created=datetime.datetime.utcnow()))
if (model.active, model.ga_domain, model.email) != (True, ga_domain, email):
model.active, model.ga_domain, model.email = True, ga_domain, email
model.put()
return User.FromModel(model)
def GetAllWithFilter(filter_fn=None):
"""Gets all users that pass a given filter.
Args:
filter_fn: Function that takes a User and returns True/False to indicate
if it should be included in the returned list of users. This filter is
applied post-query.
Returns:
A list of User objects.
"""
return _GetAllWithFilter(_UserModel.query(), User.FromModel, filter_fn)
def GetAll():
"""Yields all the User objects."""
current = GetCurrent()
if current:
# If this is the user's first login, we may have only just stored the
# UserModel, so it might not be indexed yet; ensure that it's included.
yield current
for model in _UserModel.query():
if not current or model.key.id() != current.id:
yield User.FromModel(model)
def GetAllGoogleAccounts():
"""Yields all the Google Account User objects."""
for model in _GoogleAccount.query():
yield utils.Struct.FromModel(model)
def GetAllGoogleAccountsWithFilter(filter_fn=None):
"""Gets all google accounts that pass a given filter.
Args:
filter_fn: Function that takes a struct based on _GoogleAccount and
returns True/False to indicate if it should be included in the
returned list of users. This filter is applied post-query.
Returns:
A list of structures based on _GoogleAccounts.
"""
return _GetAllWithFilter(
_GoogleAccount.query(), utils.Struct.FromModel, filter_fn)
def _GetAllWithFilter(query, to_struct_fn, filter_fn=None):
"""Retrieves ndb models from the datastore in batches.
There is a query size limit (1Mb or 1000 entries), so to avoid it, we
retrieve models in smaller batches.
Args:
query: ndb Query for retrieving models.
to_struct_fn: Function that takes a model and returns a structure wrapper of
it.
filter_fn: Function that takes a structure based on retrieved model
and returns True/False for whether to include this entity in the
results. This filter is applied post-query.
Returns:
A list of structures based on datastore models.
"""
results = []
cursor, more = (None, True)
while more:
models, cursor, more = query.fetch_page(_ACCOUNTS_FETCH_SIZE,
start_cursor=cursor)
for model in models:
struct = to_struct_fn(model)
if not filter_fn or filter_fn(struct):
results.append(struct)
return results
def GetForEmail(email):
"""Gets (or if needed, creates) the User object for a given e-mail address."""
# First see if the e-mail address is associated with a known Google Account
# for which we have a corresponding UserModel.
gae_user_id = _EmailToGaeUserId(email)
if gae_user_id:
ga = _GoogleAccount.get_by_id(gae_user_id)
if ga:
return Get(ga.uid)
# Otherwise, look for a UserModel with the given e-mail address.
model = _UserModel.query(_UserModel.email == email).get()
if not model:
# The ".test" TLD is reserved for testing. For our test accounts, we make
# the uid match the part of the address before '@' to simplify testing.
if email[0] not in '0123456789' and email.endswith('.test'):
uid = email.split('@')[0] # guaranteed non-numeric
else:
uid = _GenerateNextUid()
# We have the uid and the e-mail address for this user, but not ga_domain.
# Initially assume no ga_domain; when the user logs in, the ga_domain
# property will be updated by GetCurrent().
model = _UserModel(id=uid, email=email, created=datetime.datetime.utcnow())
model.put()
# If we discovered a Google Account, associate it with the UserModel.
if gae_user_id:
_GoogleAccount(id=gae_user_id, uid=model.key.id()).put()
return User.FromModel(model)
def SetWelcomeMessageDismissed(uid, value):
"""Sets the welcome_message_dismissed flag for a given user."""
model = _GetModel(uid)
model.welcome_message_dismissed = bool(value)
model.put()
def SetMarketingConsent(uid, value):
"""Sets the marketing_consent flag for a given user."""
model = _GetModel(uid)
model.marketing_consent = bool(value)
model.marketing_consent_answered = True
model.put()
def GetLoginUrl(url):
"""Gets a URL that accepts a sign-in and then proceeds to the given URL."""
if utils.IsDevelopmentServer():
root_path = config.Get('root_path') or ''
return root_path + '/.login?redirect=' + urllib.quote(url)
return gae_users.create_login_url(url)
def GetLogoutUrl(url):
"""Gets a URL that signs the user out and then proceeds to the given URL."""
if utils.IsDevelopmentServer():
root_path = config.Get('root_path') or ''
return root_path + '/.login?logout=1&redirect=' + urllib.quote(url)
return gae_users.create_logout_url(url)