Merge lp:~canonical-isd-hackers/canonical-identity-provider/limit-sql-queries into lp:canonical-identity-provider/release

Proposed by Łukasz Czyżykowski
Status: Merged
Approved by: Ricardo Kirkner
Approved revision: no longer in the source branch.
Merged at revision: 188
Proposed branch: lp:~canonical-isd-hackers/canonical-identity-provider/limit-sql-queries
Merge into: lp:canonical-identity-provider/release
Diff against target: 420 lines (+115/-106)
10 files modified
.bzrignore (+4/-0)
identityprovider/auth.py (+35/-32)
identityprovider/forms.py (+18/-31)
identityprovider/models/account.py (+31/-26)
identityprovider/tests/test_models_account.py (+8/-7)
identityprovider/tests/test_views_ui.py (+1/-1)
identityprovider/utils.py (+10/-0)
identityprovider/views/ui.py (+5/-7)
identityprovider/views/utils.py (+1/-0)
wsgi_test_server.py (+2/-2)
To merge this branch: bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/limit-sql-queries
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+67853@code.launchpad.net

Commit message

Limited number of SQL queries when user logs in.

Description of the change

Overview
========
This branch limits number of queries issued by the SSO when user is logging in.

Details
=======
There are couple ways in which the SQL queries are limited:
- using django's ability to query over foreign keys (which means that Django will issue a SQL JOIN),
- using select_related() to get linked models,
- not getting the object twice from the db (LoginForm and login view).

Testing
=======
Follow instructions in README and then '$ fab test' to verify all things are still working. Using $ fab run and logging in you can see exact queries which are issued (with summary saying 11 queries with 2 duplicates).

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

Excellent work!

Really like the changes around l. 303-308

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '.bzrignore'
2--- .bzrignore 2011-06-29 19:47:22 +0000
3+++ .bzrignore 2011-07-15 12:32:45 +0000
4@@ -13,3 +13,7 @@
5 *.xml
6 tags
7 .backup
8+
9+# other
10+.pyflymakerc*
11+.noseids
12
13=== modified file 'identityprovider/auth.py'
14--- identityprovider/auth.py 2011-05-30 14:23:26 +0000
15+++ identityprovider/auth.py 2011-07-15 12:32:45 +0000
16@@ -9,55 +9,57 @@
17 from piston.authentication import OAuthAuthentication
18 from piston.oauth import OAuthError as PistonOAuthError
19
20-from identityprovider.models import Account, AccountPassword, EmailAddress
21+from identityprovider.models import Account, AccountPassword
22 from identityprovider.models.api import APIUser
23-from identityprovider.models.const import EmailStatus
24-from identityprovider.utils import validate_launchpad_password
25+from identityprovider.models.const import EmailStatus, AccountStatus
26+from identityprovider.utils import (
27+ validate_launchpad_password, get_object_or_none)
28
29
30 class LaunchpadBackend(object):
31
32+ valid_email_statuses = (
33+ EmailStatus.PREFERRED, EmailStatus.VALIDATED, EmailStatus.NEW)
34+
35 def authenticate(self, username=None, password=None,
36 encrypted_password=None):
37 try:
38- email = EmailAddress.objects.get(email__iexact=username)
39- except EmailAddress.DoesNotExist:
40- return None
41-
42- if email.status not in (EmailStatus.PREFERRED,
43- EmailStatus.VALIDATED,
44- EmailStatus.NEW):
45- return None
46- account = email.account
47-
48- # only allow authentication if account is active
49+ account = Account.objects.select_related('accountpassword').get(
50+ emailaddress__email__iexact=username,
51+ emailaddress__status__in=self.valid_email_statuses)
52+ except Account.DoesNotExist:
53+ return None
54+
55 if not account.is_active:
56 return None
57
58 try:
59- expected_encrypted = account.accountpassword.password
60+ ap = account.accountpassword
61+ if ap is None:
62+ return None
63 except AccountPassword.DoesNotExist:
64- # password lost somehow, user should reset it
65- return None
66-
67- def ok():
68- account.last_login = datetime.now()
69- return account
70-
71- if password is not None:
72- if validate_launchpad_password(password, expected_encrypted):
73- return ok()
74- elif encrypted_password is not None:
75- if encrypted_password == expected_encrypted:
76- return ok()
77+ return None
78+
79+ try:
80+ if password is not None:
81+ if not validate_launchpad_password(password, ap.password):
82+ return None
83+ elif encrypted_password is not None:
84+ if encrypted_password != ap.password:
85+ return None
86+ else:
87+ return None
88+ except (UnicodeDecodeError, UnicodeEncodeError):
89+ # Password not ASCII, we must fail
90+ return None
91+
92+ account.last_login = datetime.now()
93+ return account
94
95 def get_user(self, user_id):
96 """ Returns a Launchpad Account object instead of Django's built-in
97 User object. """
98- try:
99- return Account.objects.get(pk=user_id)
100- except Account.DoesNotExist:
101- return None
102+ return get_object_or_none(Account.objects.select_related(), pk=user_id)
103
104
105 def basic_authenticate(username, password):
106@@ -93,6 +95,7 @@
107 resp.status_code = 401
108 return resp
109
110+
111 def oauth_authenticate(oauth_consumer, oauth_token, parameters):
112 """Currently only checks that given consumer and token are in database"""
113 try:
114
115=== modified file 'identityprovider/forms.py'
116--- identityprovider/forms.py 2011-07-11 20:27:02 +0000
117+++ identityprovider/forms.py 2011-07-15 12:32:45 +0000
118@@ -59,39 +59,26 @@
119 def clean(self):
120 if 'email' in self.cleaned_data and 'password' in self.cleaned_data:
121 email = self.cleaned_data['email']
122- verified = False
123- try:
124- email_addr = EmailAddress.objects.get(email__iexact=email)
125- verified = email_addr.status in [EmailStatus.VALIDATED,
126- EmailStatus.OLD, EmailStatus.PREFERRED]
127- except EmailAddress.DoesNotExist:
128- pass
129-
130- if not verified:
131- raise forms.ValidationError(
132- mark_safe(_("Password didn't match.")))
133-
134 password = self.cleaned_data['password']
135- try:
136- user = auth.authenticate(username=email, password=password)
137- except UnicodeEncodeError:
138- # Password not ASCII, we must fail
139- raise forms.ValidationError(mark_safe(
140- _("Password didn't match.")))
141-
142- account = Account.objects.get_by_email(email)
143- if not account.is_active:
144- raise forms.ValidationError(_("Your account has been "
145- "deactivated"))
146-
147- if user is not None and user.is_active:
148- return self.cleaned_data
149- else:
150- raise forms.ValidationError(mark_safe(
151- _("Password didn't match.")))
152+
153+ account = auth.authenticate(username=email, password=password)
154+ if account is None:
155+ account = Account.objects.get_by_email(email)
156+ if account and not account.is_active:
157+ raise forms.ValidationError(_("Your account has been "
158+ "deactivated"))
159+ else:
160+ raise forms.ValidationError(
161+ mark_safe(_("Password didn't match.")))
162+
163+ # Pass account to the view, as it's already authenticated and
164+ # there's no good reason to do exactly the same call there.
165+ self.cleaned_data['account'] = account
166+ return self.cleaned_data
167
168
169 class ForgotPasswordForm(GenericEmailForm):
170+
171 def clean_email(self):
172 if 'email' in self.cleaned_data:
173 email = self.cleaned_data['email']
174@@ -109,12 +96,12 @@
175 password = fields.CharField(error_messages=default_errors,
176 widget=widgets.PasswordInput(attrs={
177 'class': 'textType',
178- 'size': '20'
179+ 'size': '20',
180 }))
181 passwordconfirm = fields.CharField(error_messages=default_errors,
182 widget=widgets.PasswordInput(attrs={
183 'class': 'textType',
184- 'size': '20'
185+ 'size': '20',
186 }))
187
188 def clean_password(self):
189
190=== modified file 'identityprovider/models/account.py'
191--- identityprovider/models/account.py 2011-07-07 14:02:24 +0000
192+++ identityprovider/models/account.py 2011-07-15 12:32:45 +0000
193@@ -16,15 +16,16 @@
194 from identityprovider.utils import (
195 encrypt_launchpad_password,
196 generate_openid_identifier,
197+ get_object_or_none,
198 )
199
200-__all__ = (
201+__all__ = [
202 'Account',
203 'AccountPassword',
204 'DisplaynameField',
205 'PasswordField',
206- 'LPOpenIdIdentifier'
207-)
208+ 'LPOpenIdIdentifier',
209+]
210
211
212 class AccountManager(models.Manager):
213@@ -56,12 +57,8 @@
214 return account
215
216 def get_by_email(self, email):
217- from identityprovider.models import EmailAddress
218- try:
219- email = EmailAddress.objects.get(email__iexact=email)
220- return email.account
221- except EmailAddress.DoesNotExist:
222- return None
223+ return get_object_or_none(Account.objects.select_related(),
224+ emailaddress__email__iexact=email)
225
226
227 class DisplaynameField(models.TextField):
228@@ -174,28 +171,36 @@
229 self._person = persons[0]
230 return getattr(self, '_person', None)
231
232+ @property
233+ def user(self):
234+ if not hasattr(self, '_user'):
235+ # When in read only mode we don't want to create users so we'll
236+ # return what is or None if not there
237+ if settings.READ_ONLY_MODE:
238+ self._user = get_object_or_none(
239+ User, username=self.openid_identifier)
240+ else:
241+ if self.preferredemail:
242+ self._user, _ = User.objects.get_or_create(
243+ username=self.openid_identifier,
244+ defaults={'email': self.preferredemail})
245+ else:
246+ return None
247+
248+ return self._user
249+
250 def _get_last_login(self):
251- try:
252- user = User.objects.get(username=self.openid_identifier)
253- return user.last_login
254- except User.DoesNotExist:
255- return None
256+ if self.user is not None:
257+ return self.user.last_login
258
259 def _set_last_login(self, login_datetime):
260+ # we can live without last_login
261 if settings.READ_ONLY_MODE:
262- # we can live without last_login
263 return
264- try:
265- user = User.objects.get(username=self.openid_identifier)
266- except User.DoesNotExist:
267- email = self.preferredemail
268- if email is None:
269- return
270- else:
271- user = User.objects.create_user(self.openid_identifier,
272- email.email)
273- user.last_login = login_datetime
274- user.save()
275+
276+ if self.user is not None:
277+ self.user.last_login = login_datetime
278+ self.user.save()
279
280 last_login = property(_get_last_login, _set_last_login)
281
282
283=== modified file 'identityprovider/tests/test_models_account.py'
284--- identityprovider/tests/test_models_account.py 2011-07-07 14:02:24 +0000
285+++ identityprovider/tests/test_models_account.py 2011-07-15 12:32:45 +0000
286@@ -40,8 +40,7 @@
287
288 def test_last_login_when_user_doesnt_exists(self):
289 account = self.get_new_account()
290-
291- self.assertTrue(account.last_login is None)
292+ self.assertTrue(account.last_login is not None)
293
294 def test_last_login_when_user_exists(self):
295 account = self.get_existing_account()
296@@ -75,12 +74,14 @@
297
298 def test_set_last_login_when_no_preferredemail(self):
299 account = self.get_new_account()
300- for email in account.emailaddress_set.all():
301- email.status = EmailStatus.NEW
302- email.save()
303+ account.emailaddress_set.all().update(status=EmailStatus.NEW)
304 account.last_login = datetime.now()
305- self.assertRaises(User.DoesNotExist, User.objects.get,
306- username=account.openid_identifier)
307+ # Changed from assertRaises, as using it to test for database
308+ # exceptions causes transaction to be aborted and all tests following
309+ # it will fail. (It's only the case if the test case inherits
310+ # django.test.TestCase)
311+ self.assertEqual(
312+ 0, User.objects.filter(username=account.openid_identifier).count())
313
314 def test_is_active(self):
315 account = self.get_new_account()
316
317=== modified file 'identityprovider/tests/test_views_ui.py'
318--- identityprovider/tests/test_views_ui.py 2011-07-12 21:42:47 +0000
319+++ identityprovider/tests/test_views_ui.py 2011-07-15 12:32:45 +0000
320@@ -753,7 +753,7 @@
321 self.assertTemplateUsed(r, 'account/suspended.html')
322
323 def test_invalid_email_does_not_blow_up(self):
324- r = _post_new_account(self.client, email='what~@ever.com')
325+ r = _post_new_account(self.client, email='what<~@ever.com')
326 self.assertEquals(200, r.status_code)
327 self.assertContains(r, 'Invalid email')
328
329
330=== modified file 'identityprovider/utils.py'
331--- identityprovider/utils.py 2011-06-29 21:29:09 +0000
332+++ identityprovider/utils.py 2011-07-15 12:32:45 +0000
333@@ -12,6 +12,7 @@
334 from email.Header import Header
335 from email.Utils import formataddr
336
337+from django.core.exceptions import ObjectDoesNotExist
338 from django.utils.translation import ugettext as _
339
340 SALT_LENGTH = 20
341@@ -176,6 +177,15 @@
342 return data, headers
343
344
345+def get_object_or_none(qs_or_model, **kwargs):
346+ if hasattr(qs_or_model, 'objects'):
347+ qs_or_model = qs_or_model.objects.all()
348+ try:
349+ return qs_or_model.get(**kwargs)
350+ except ObjectDoesNotExist:
351+ return None
352+
353+
354 def is_django_13():
355 import django
356 return django.VERSION[:2] == (1, 3)
357
358=== modified file 'identityprovider/views/ui.py'
359--- identityprovider/views/ui.py 2011-07-12 21:42:47 +0000
360+++ identityprovider/views/ui.py 2011-07-15 12:32:45 +0000
361@@ -24,9 +24,9 @@
362 from identityprovider.branding import current_brand
363 from identityprovider.decorators import (check_readonly, dont_cache,
364 guest_required, limitlogin, requires_cookies)
365-from identityprovider.forms import (LoginForm, ForgotPasswordForm,
366- ResetPasswordForm, OldNewAccountForm, NewAccountForm, ConfirmNewAccountForm,
367- TokenForm)
368+from identityprovider.forms import (
369+ LoginForm, ForgotPasswordForm, ResetPasswordForm, OldNewAccountForm,
370+ NewAccountForm, ConfirmNewAccountForm, TokenForm)
371 from identityprovider.models import (Account, AccountPassword, AuthToken,
372 AuthTokenFactory, EmailAddress, get_type_of_token, verify_token_string)
373 from identityprovider.models.authtoken import InvalidEmailException
374@@ -66,10 +66,8 @@
375 if request.method == 'POST':
376 form = LoginForm(request.POST)
377 if form.is_valid():
378- username = form.cleaned_data['email']
379- password = form.cleaned_data['password']
380- user = auth.authenticate(username=username, password=password)
381- auth.login(request, user)
382+ account = form.cleaned_data['account']
383+ auth.login(request, account)
384 next = request.POST.get('next')
385 limitlogin().reset_count(request)
386 if next and _is_safe_redirect_url(next):
387
388=== modified file 'identityprovider/views/utils.py'
389--- identityprovider/views/utils.py 2010-06-17 02:10:58 +0000
390+++ identityprovider/views/utils.py 2011-07-15 12:32:45 +0000
391@@ -12,6 +12,7 @@
392 else:
393 return "/"
394
395+
396 def set_session_token_info(session, token):
397 """Places information about the token into the session in a
398 uniform place. Specifically, places the token's e-mail address at
399
400=== modified file 'wsgi_test_server.py'
401--- wsgi_test_server.py 2011-02-22 00:01:16 +0000
402+++ wsgi_test_server.py 2011-07-15 12:32:45 +0000
403@@ -17,7 +17,7 @@
404 from django.conf import settings
405 from django.core import mail
406 from django.db import connection
407-from django.test.utils import TestSMTPConnection
408+from django.core.mail.backends import locmem
409
410 from wsgiref.simple_server import (make_server, WSGIRequestHandler,
411 ServerHandler)
412@@ -34,7 +34,7 @@
413 )
414 settings.SSO_RESTRICT_RP = False
415 # Set up email sending into a sandbox
416-mail.SMTPConnection = TestSMTPConnection
417+mail.SMTPConnection = locmem.EmailBackend
418 mail.outbox = []
419
420 server_port = 80 # We need to be run here for the LP tests to find us