Merge lp:~matiasb/canonical-identity-provider/phoneid-as-emails into lp:canonical-identity-provider/release
- phoneid-as-emails
- Merge into trunk
Proposed by
Matias Bordese
Status: | Merged |
---|---|
Approved by: | Natalia Bidart |
Approved revision: | no longer in the source branch. |
Merged at revision: | 657 |
Proposed branch: | lp:~matiasb/canonical-identity-provider/phoneid-as-emails |
Merge into: | lp:canonical-identity-provider/release |
Diff against target: |
447 lines (+319/-1) 8 files modified
api/urls.py (+3/-0) api/v20/handlers.py (+64/-1) api/v20/tests/test_phone_login.py (+155/-0) identityprovider/login.py (+1/-0) identityprovider/models/emailaddress.py (+32/-0) identityprovider/tests/unit/test_models_emailaddress.py (+32/-0) webui/templates/account/emails.html (+2/-0) webui/tests/test_views_account.py (+30/-0) |
To merge this branch: | bzr merge lp:~matiasb/canonical-identity-provider/phoneid-as-emails |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Natalia Bidart (community) | Approve | ||
Review via email: mp+149055@code.launchpad.net |
Commit message
Added new login API (behind a flag) to allow login with a phone id.
Description of the change
Added new login API (behind a flag) to allow login with a phone id.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'api/urls.py' | |||
2 | --- api/urls.py 2013-02-04 11:13:04 +0000 | |||
3 | +++ api/urls.py 2013-02-18 16:43:22 +0000 | |||
4 | @@ -42,6 +42,7 @@ | |||
5 | 42 | handler=v20.AccountsHandler, authentication=ApiOAuthAuthentication()) | 42 | handler=v20.AccountsHandler, authentication=ApiOAuthAuthentication()) |
6 | 43 | v2emails = Resource(handler=v20.EmailsHandler) | 43 | v2emails = Resource(handler=v20.EmailsHandler) |
7 | 44 | v2login = Resource(handler=v20.AccountLoginHandler) | 44 | v2login = Resource(handler=v20.AccountLoginHandler) |
8 | 45 | v2login_phone = Resource(handler=v20.AccountPhoneLoginHandler) | ||
9 | 45 | v2registration = Resource(handler=v20.AccountRegistrationHandler) | 46 | v2registration = Resource(handler=v20.AccountRegistrationHandler) |
10 | 46 | v2requests = Resource(handler=v20.RequestsHandler) | 47 | v2requests = Resource(handler=v20.RequestsHandler) |
11 | 47 | 48 | ||
12 | @@ -78,6 +79,8 @@ | |||
13 | 78 | url(r'^v2/tokens$', v2login, name='api-login'), | 79 | url(r'^v2/tokens$', v2login, name='api-login'), |
14 | 79 | url(r'^v2/accounts/(\w+)$', v2accounts, name='api-account'), | 80 | url(r'^v2/accounts/(\w+)$', v2accounts, name='api-account'), |
15 | 80 | url(r'^v2/requests/validate$', v2requests, name='api-requests'), | 81 | url(r'^v2/requests/validate$', v2requests, name='api-requests'), |
16 | 82 | # login from phone, with a phone user id | ||
17 | 83 | url(r'^v2/tokens/phone$', v2login_phone, name='api-login-phone'), | ||
18 | 81 | # temporarily hooked up so we can do reverse() | 84 | # temporarily hooked up so we can do reverse() |
19 | 82 | url(r'^v2/emails/(.*)$', v2emails, name='api-email'), | 85 | url(r'^v2/emails/(.*)$', v2emails, name='api-email'), |
20 | 83 | url(r'v2/tokens/(.*)$', v2login, name='api-token'), | 86 | url(r'v2/tokens/(.*)$', v2login, name='api-token'), |
21 | 84 | 87 | ||
22 | === modified file 'api/v20/handlers.py' | |||
23 | --- api/v20/handlers.py 2013-02-06 15:14:39 +0000 | |||
24 | +++ api/v20/handlers.py 2013-02-18 16:43:22 +0000 | |||
25 | @@ -20,7 +20,7 @@ | |||
26 | 20 | AuthenticationError, | 20 | AuthenticationError, |
27 | 21 | authenticate_user, | 21 | authenticate_user, |
28 | 22 | ) | 22 | ) |
30 | 23 | from identityprovider.models import Account, twofactor | 23 | from identityprovider.models import Account, EmailAddress, twofactor |
31 | 24 | from identityprovider.models.captcha import Captcha, VerifyCaptchaError | 24 | from identityprovider.models.captcha import Captcha, VerifyCaptchaError |
32 | 25 | from identityprovider.store import SSODataStore | 25 | from identityprovider.store import SSODataStore |
33 | 26 | 26 | ||
34 | @@ -137,6 +137,69 @@ | |||
35 | 137 | return response | 137 | return response |
36 | 138 | 138 | ||
37 | 139 | 139 | ||
38 | 140 | class AccountPhoneLoginHandler(BaseHandler): | ||
39 | 141 | allowed_methods = ('POST', ) | ||
40 | 142 | |||
41 | 143 | @require_mime('json') | ||
42 | 144 | def create(self, request): | ||
43 | 145 | if not gargoyle.is_active('LOGIN_BY_PHONE'): | ||
44 | 146 | return errors.RESOURCE_NOT_FOUND() | ||
45 | 147 | |||
46 | 148 | data = request.data | ||
47 | 149 | # email should be available the first time, to bind account/phone | ||
48 | 150 | # after that, get email from phoneid in the token name | ||
49 | 151 | email = data.get('email') | ||
50 | 152 | try: | ||
51 | 153 | phone_id = data['phone_id'] | ||
52 | 154 | password = data['password'] | ||
53 | 155 | token_name = data['token_name'] | ||
54 | 156 | except KeyError: | ||
55 | 157 | expected = set(('phone_id', 'password', 'token_name')) | ||
56 | 158 | missing = dict((k, 'field required') for k in expected - set(data)) | ||
57 | 159 | return errors.INVALID_DATA(**missing) | ||
58 | 160 | |||
59 | 161 | try: | ||
60 | 162 | phone_email = EmailAddress.objects.get_from_phone_id(phone_id) | ||
61 | 163 | login_email = phone_email.email | ||
62 | 164 | except EmailAddress.DoesNotExist: | ||
63 | 165 | phone_email = None | ||
64 | 166 | login_email = email | ||
65 | 167 | |||
66 | 168 | if login_email is None: | ||
67 | 169 | return errors.INVALID_DATA(phone_id='invalid value') | ||
68 | 170 | |||
69 | 171 | try: | ||
70 | 172 | account = authenticate_user(login_email, password) | ||
71 | 173 | except AccountSuspended: | ||
72 | 174 | return errors.ACCOUNT_SUSPENDED() | ||
73 | 175 | except AccountDeactivated: | ||
74 | 176 | return errors.ACCOUNT_DEACTIVATED() | ||
75 | 177 | except AuthenticationError: | ||
76 | 178 | return errors.INVALID_CREDENTIALS() | ||
77 | 179 | |||
78 | 180 | # TODO: here we should check for 2fa, avoiding for demo | ||
79 | 181 | |||
80 | 182 | if phone_email is None: | ||
81 | 183 | # user is authenticated through email, | ||
82 | 184 | # adding phone id as email for future login | ||
83 | 185 | EmailAddress.objects.create_from_phone_id(phone_id, account) | ||
84 | 186 | |||
85 | 187 | token = account.get_or_create_oauth_token(token_name) | ||
86 | 188 | response = rc.ALL_OK | ||
87 | 189 | response.content = { | ||
88 | 190 | "consumer_key": token.consumer.key, | ||
89 | 191 | "consumer_secret": token.consumer.secret, | ||
90 | 192 | "token_key": token.token, | ||
91 | 193 | "token_secret": token.token_secret, | ||
92 | 194 | "token_name": token_name, | ||
93 | 195 | "date_created": token.created_at, | ||
94 | 196 | "date_updated": token.updated_at, | ||
95 | 197 | "href": reverse('api-token', args=(token.token,)), | ||
96 | 198 | "openid": account.openid_identifier, | ||
97 | 199 | } | ||
98 | 200 | return response | ||
99 | 201 | |||
100 | 202 | |||
101 | 140 | class EmailsHandler(BaseHandler): | 203 | class EmailsHandler(BaseHandler): |
102 | 141 | allowed_methods = ('GET',) | 204 | allowed_methods = ('GET',) |
103 | 142 | 205 | ||
104 | 143 | 206 | ||
105 | === added file 'api/v20/tests/test_phone_login.py' | |||
106 | --- api/v20/tests/test_phone_login.py 1970-01-01 00:00:00 +0000 | |||
107 | +++ api/v20/tests/test_phone_login.py 2013-02-18 16:43:22 +0000 | |||
108 | @@ -0,0 +1,155 @@ | |||
109 | 1 | |||
110 | 2 | from django.core.urlresolvers import reverse | ||
111 | 3 | from gargoyle.testutils import switches | ||
112 | 4 | from mock import patch | ||
113 | 5 | |||
114 | 6 | from identityprovider.login import ( | ||
115 | 7 | AuthenticationError, | ||
116 | 8 | AccountDeactivated, | ||
117 | 9 | AccountSuspended, | ||
118 | 10 | ) | ||
119 | 11 | from identityprovider.models.emailaddress import PHONE_EMAIL_DOMAIN | ||
120 | 12 | from identityprovider.tests import DEFAULT_USER_PASSWORD | ||
121 | 13 | from identityprovider.tests.utils import ( | ||
122 | 14 | SSOBaseTestCase, | ||
123 | 15 | ) | ||
124 | 16 | |||
125 | 17 | from api.v20 import handlers | ||
126 | 18 | from api.v20.tests.utils import call | ||
127 | 19 | |||
128 | 20 | |||
129 | 21 | handler = handlers.AccountPhoneLoginHandler() | ||
130 | 22 | API_URL = reverse('api-login-phone') | ||
131 | 23 | API_DATA = dict( | ||
132 | 24 | email='foo@bar.com', phone_id='tel:+1234567890', | ||
133 | 25 | password='foobar', token_name='token_name') | ||
134 | 26 | |||
135 | 27 | |||
136 | 28 | class PhoneLoginHandlerTestCase(SSOBaseTestCase): | ||
137 | 29 | fixtures = ['test'] | ||
138 | 30 | |||
139 | 31 | def test_disabled_by_default(self): | ||
140 | 32 | response, json_body = call(handler.create, API_URL, API_DATA) | ||
141 | 33 | self.assertEqual(response.status_code, 404) | ||
142 | 34 | |||
143 | 35 | @switches(LOGIN_BY_PHONE=True) | ||
144 | 36 | def test_login_required_parameters(self): | ||
145 | 37 | response, json_body = call(handler.create, API_URL, {}) | ||
146 | 38 | self.assertEqual(response.status_code, 400) | ||
147 | 39 | self.assertEqual(json_body, { | ||
148 | 40 | 'code': 'INVALID_DATA', | ||
149 | 41 | 'extra': { | ||
150 | 42 | 'phone_id': 'field required', 'password': 'field required', | ||
151 | 43 | 'token_name': 'field required' | ||
152 | 44 | }, | ||
153 | 45 | 'message': 'Invalid request data'}) | ||
154 | 46 | |||
155 | 47 | @switches(LOGIN_BY_PHONE=True) | ||
156 | 48 | def test_account_suspended(self): | ||
157 | 49 | self.assert_failed_login('ACCOUNT_SUSPENDED', AccountSuspended) | ||
158 | 50 | |||
159 | 51 | @switches(LOGIN_BY_PHONE=True) | ||
160 | 52 | def test_account_deactivated(self): | ||
161 | 53 | self.assert_failed_login('ACCOUNT_DEACTIVATED', AccountDeactivated) | ||
162 | 54 | |||
163 | 55 | @switches(LOGIN_BY_PHONE=True) | ||
164 | 56 | def test_failed_login(self): | ||
165 | 57 | self.assert_failed_login('INVALID_CREDENTIALS', AuthenticationError) | ||
166 | 58 | |||
167 | 59 | @switches(LOGIN_BY_PHONE=True) | ||
168 | 60 | def assert_failed_login(self, code, exception): | ||
169 | 61 | status_code = 403 | ||
170 | 62 | if exception is AuthenticationError: | ||
171 | 63 | status_code = 401 | ||
172 | 64 | |||
173 | 65 | with patch('api.v20.handlers.authenticate_user') as mock_authenticate: | ||
174 | 66 | mock_authenticate.side_effect = exception | ||
175 | 67 | response, json_body = call(handler.create, API_URL, API_DATA) | ||
176 | 68 | |||
177 | 69 | self.assertEqual(response.status_code, status_code) | ||
178 | 70 | mock_authenticate.assert_called_once_with('foo@bar.com', 'foobar') | ||
179 | 71 | self.assertEqual(json_body['code'], code) | ||
180 | 72 | |||
181 | 73 | @switches(LOGIN_BY_PHONE=True) | ||
182 | 74 | @switches(ALLOW_UNVALIDATED=True) | ||
183 | 75 | def test_login_add_phone_email(self): | ||
184 | 76 | account = self.factory.make_account() | ||
185 | 77 | email = account.preferredemail | ||
186 | 78 | # no unverified_emails | ||
187 | 79 | assert len(account.unverified_emails()) == 0 | ||
188 | 80 | |||
189 | 81 | data = {'phone_id': 'tel:+1234567890', 'email': email.email, | ||
190 | 82 | 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'} | ||
191 | 83 | response, json_body = call(handler.create, API_URL, data) | ||
192 | 84 | |||
193 | 85 | self.assertEqual(response.status_code, 200) | ||
194 | 86 | token = account.get_or_create_oauth_token('token_name') | ||
195 | 87 | expected_response = { | ||
196 | 88 | "consumer_key": token.consumer.key, | ||
197 | 89 | "consumer_secret": token.consumer.secret, | ||
198 | 90 | "token_key": token.token, | ||
199 | 91 | "token_secret": token.token_secret, | ||
200 | 92 | "token_name": 'token_name', | ||
201 | 93 | "date_created": token.created_at, | ||
202 | 94 | "href": reverse('api-token', args=(token.token,)), | ||
203 | 95 | "openid": account.openid_identifier, | ||
204 | 96 | } | ||
205 | 97 | # ignore date_updated | ||
206 | 98 | json_body.pop('date_updated') | ||
207 | 99 | self.assertEqual(json_body, expected_response) | ||
208 | 100 | # check added phone email | ||
209 | 101 | unverified_emails = account.unverified_emails() | ||
210 | 102 | self.assertEqual(len(unverified_emails), 1) | ||
211 | 103 | phone_email = unverified_emails[0].email | ||
212 | 104 | self.assertTrue(phone_email.endswith(PHONE_EMAIL_DOMAIN)) | ||
213 | 105 | |||
214 | 106 | @switches(LOGIN_BY_PHONE=True) | ||
215 | 107 | @switches(ALLOW_UNVALIDATED=True) | ||
216 | 108 | def test_login_without_email_or_added_phone_email(self): | ||
217 | 109 | account = self.factory.make_account() | ||
218 | 110 | # no unverified_emails | ||
219 | 111 | assert len(account.unverified_emails()) == 0 | ||
220 | 112 | |||
221 | 113 | data = {'phone_id': 'tel:+1234567890', | ||
222 | 114 | 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'} | ||
223 | 115 | response, json_body = call(handler.create, API_URL, data) | ||
224 | 116 | |||
225 | 117 | self.assertEqual(response.status_code, 400) | ||
226 | 118 | self.assertEqual(json_body, { | ||
227 | 119 | 'code': 'INVALID_DATA', | ||
228 | 120 | 'extra': {'phone_id': 'invalid value'}, | ||
229 | 121 | 'message': 'Invalid request data'}) | ||
230 | 122 | |||
231 | 123 | @switches(LOGIN_BY_PHONE=True) | ||
232 | 124 | @switches(ALLOW_UNVALIDATED=True) | ||
233 | 125 | def test_login_with_phone_email(self): | ||
234 | 126 | account = self.factory.make_account() | ||
235 | 127 | email = account.preferredemail | ||
236 | 128 | # no unverified_emails | ||
237 | 129 | assert len(account.unverified_emails()) == 0 | ||
238 | 130 | |||
239 | 131 | # login first with email and phone id | ||
240 | 132 | data = {'phone_id': 'tel:+1234567890', 'email': email.email, | ||
241 | 133 | 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'} | ||
242 | 134 | response, json_body = call(handler.create, API_URL, data) | ||
243 | 135 | assert response.status_code == 200 | ||
244 | 136 | |||
245 | 137 | # now we should be able to login with phone id only | ||
246 | 138 | data = {'phone_id': 'tel:+1234567890', | ||
247 | 139 | 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'} | ||
248 | 140 | response, json_body = call(handler.create, API_URL, data) | ||
249 | 141 | self.assertEqual(response.status_code, 200) | ||
250 | 142 | token = account.get_or_create_oauth_token('token_name') | ||
251 | 143 | expected_response = { | ||
252 | 144 | "consumer_key": token.consumer.key, | ||
253 | 145 | "consumer_secret": token.consumer.secret, | ||
254 | 146 | "token_key": token.token, | ||
255 | 147 | "token_secret": token.token_secret, | ||
256 | 148 | "token_name": 'token_name', | ||
257 | 149 | "date_created": token.created_at, | ||
258 | 150 | "href": reverse('api-token', args=(token.token,)), | ||
259 | 151 | "openid": account.openid_identifier, | ||
260 | 152 | } | ||
261 | 153 | # ignore date_updated | ||
262 | 154 | json_body.pop('date_updated') | ||
263 | 155 | self.assertEqual(json_body, expected_response) | ||
264 | 0 | 156 | ||
265 | === modified file 'identityprovider/login.py' | |||
266 | --- identityprovider/login.py 2013-01-03 11:41:12 +0000 | |||
267 | +++ identityprovider/login.py 2013-02-18 16:43:22 +0000 | |||
268 | @@ -43,6 +43,7 @@ | |||
269 | 43 | else: | 43 | else: |
270 | 44 | raise AuthenticationError(_("Password didn't match.")) | 44 | raise AuthenticationError(_("Password didn't match.")) |
271 | 45 | 45 | ||
272 | 46 | # also check for LOGIN_BY_PHONE flag conditional to canonical users | ||
273 | 46 | if not gargoyle.is_active('ALLOW_UNVALIDATED'): | 47 | if not gargoyle.is_active('ALLOW_UNVALIDATED'): |
274 | 47 | email_obj = account.emailaddress_set.get(email__iexact=email) | 48 | email_obj = account.emailaddress_set.get(email__iexact=email) |
275 | 48 | if email_obj.status == EmailStatus.NEW: | 49 | if email_obj.status == EmailStatus.NEW: |
276 | 49 | 50 | ||
277 | === modified file 'identityprovider/models/emailaddress.py' | |||
278 | --- identityprovider/models/emailaddress.py 2013-01-25 20:39:28 +0000 | |||
279 | +++ identityprovider/models/emailaddress.py 2013-02-18 16:43:22 +0000 | |||
280 | @@ -3,6 +3,7 @@ | |||
281 | 3 | # LICENSE). | 3 | # LICENSE). |
282 | 4 | 4 | ||
283 | 5 | import datetime | 5 | import datetime |
284 | 6 | import re | ||
285 | 6 | 7 | ||
286 | 7 | from django.core.urlresolvers import reverse | 8 | from django.core.urlresolvers import reverse |
287 | 8 | from django.core.validators import validate_email | 9 | from django.core.validators import validate_email |
288 | @@ -18,6 +19,31 @@ | |||
289 | 18 | ) | 19 | ) |
290 | 19 | 20 | ||
291 | 20 | 21 | ||
292 | 22 | PHONE_EMAIL_DOMAIN = 'phone.ubuntu' | ||
293 | 23 | PHONE_EMAIL_INVALID_CHARS = re.compile(r"[^-!#$%&'*+/=?^_`{}|~0-9A-Z\.]", | ||
294 | 24 | re.IGNORECASE) | ||
295 | 25 | |||
296 | 26 | |||
297 | 27 | class EmailAddressManager(models.Manager): | ||
298 | 28 | |||
299 | 29 | def _generate_email_from_phone_id(self, phone_id): | ||
300 | 30 | # replace chars not validated by django validate_email by # | ||
301 | 31 | email = '%s@%s' % (PHONE_EMAIL_INVALID_CHARS.sub('#', phone_id), | ||
302 | 32 | PHONE_EMAIL_DOMAIN) | ||
303 | 33 | return email | ||
304 | 34 | |||
305 | 35 | def create_from_phone_id(self, phone_id, account): | ||
306 | 36 | email = self._generate_email_from_phone_id(phone_id) | ||
307 | 37 | email_address = EmailAddress.objects.create( | ||
308 | 38 | email=email, account=account, status=EmailStatus.NEW) | ||
309 | 39 | return email_address | ||
310 | 40 | |||
311 | 41 | def get_from_phone_id(self, phone_id): | ||
312 | 42 | email = self._generate_email_from_phone_id(phone_id) | ||
313 | 43 | email_address = self.get(email=email) | ||
314 | 44 | return email_address | ||
315 | 45 | |||
316 | 46 | |||
317 | 21 | class EmailAddress(models.Model): | 47 | class EmailAddress(models.Model): |
318 | 22 | email = models.TextField(validators=[validate_email]) | 48 | email = models.TextField(validators=[validate_email]) |
319 | 23 | lp_person = models.IntegerField( | 49 | lp_person = models.IntegerField( |
320 | @@ -28,6 +54,8 @@ | |||
321 | 28 | account = models.ForeignKey( | 54 | account = models.ForeignKey( |
322 | 29 | Account, db_column='account', blank=True, null=True) | 55 | Account, db_column='account', blank=True, null=True) |
323 | 30 | 56 | ||
324 | 57 | objects = EmailAddressManager() | ||
325 | 58 | |||
326 | 31 | class Meta: | 59 | class Meta: |
327 | 32 | app_label = 'identityprovider' | 60 | app_label = 'identityprovider' |
328 | 33 | db_table = u'emailaddress' | 61 | db_table = u'emailaddress' |
329 | @@ -39,6 +67,10 @@ | |||
330 | 39 | def is_preferred(self): | 67 | def is_preferred(self): |
331 | 40 | return self.status == EmailStatus.PREFERRED | 68 | return self.status == EmailStatus.PREFERRED |
332 | 41 | 69 | ||
333 | 70 | def is_verifiable(self): | ||
334 | 71 | suffix = '@%s' % PHONE_EMAIL_DOMAIN | ||
335 | 72 | return not self.email.endswith(suffix) | ||
336 | 73 | |||
337 | 42 | def is_verified(self): | 74 | def is_verified(self): |
338 | 43 | return self.status in (EmailStatus.VALIDATED, EmailStatus.PREFERRED) | 75 | return self.status in (EmailStatus.VALIDATED, EmailStatus.PREFERRED) |
339 | 44 | 76 | ||
340 | 45 | 77 | ||
341 | === modified file 'identityprovider/tests/unit/test_models_emailaddress.py' | |||
342 | --- identityprovider/tests/unit/test_models_emailaddress.py 2013-01-25 21:32:21 +0000 | |||
343 | +++ identityprovider/tests/unit/test_models_emailaddress.py 2013-02-18 16:43:22 +0000 | |||
344 | @@ -2,9 +2,34 @@ | |||
345 | 2 | 2 | ||
346 | 3 | from identityprovider.models import Account, EmailAddress | 3 | from identityprovider.models import Account, EmailAddress |
347 | 4 | from identityprovider.models.const import EmailStatus | 4 | from identityprovider.models.const import EmailStatus |
348 | 5 | from identityprovider.models.emailaddress import PHONE_EMAIL_DOMAIN | ||
349 | 5 | from identityprovider.tests.utils import SSOBaseTestCase | 6 | from identityprovider.tests.utils import SSOBaseTestCase |
350 | 6 | 7 | ||
351 | 7 | 8 | ||
352 | 9 | class EmailAddressManagerTestCase(SSOBaseTestCase): | ||
353 | 10 | fixtures = ['test'] | ||
354 | 11 | |||
355 | 12 | def test_create_from_phone_id(self): | ||
356 | 13 | account = Account.objects.get_by_email('test@canonical.com') | ||
357 | 14 | new_email = EmailAddress.objects.create_from_phone_id('tel:+123', | ||
358 | 15 | account) | ||
359 | 16 | self.assertEqual(new_email.status, EmailStatus.NEW) | ||
360 | 17 | self.assertEqual(new_email.account, account) | ||
361 | 18 | self.assertEqual(new_email.email, 'tel#+123@%s' % PHONE_EMAIL_DOMAIN) | ||
362 | 19 | |||
363 | 20 | def test_get_from_phone_id(self): | ||
364 | 21 | account = Account.objects.get_by_email('test@canonical.com') | ||
365 | 22 | new_email = EmailAddress.objects.create_from_phone_id('tel:+123', | ||
366 | 23 | account) | ||
367 | 24 | email = EmailAddress.objects.get_from_phone_id('tel:+123') | ||
368 | 25 | self.assertEqual(email, new_email) | ||
369 | 26 | |||
370 | 27 | def test_get_from_phone_id_not_exist(self): | ||
371 | 28 | self.assertRaises( | ||
372 | 29 | EmailAddress.DoesNotExist, | ||
373 | 30 | EmailAddress.objects.get_from_phone_id, 'tel:+123') | ||
374 | 31 | |||
375 | 32 | |||
376 | 8 | class EmailAddressTestCase(SSOBaseTestCase): | 33 | class EmailAddressTestCase(SSOBaseTestCase): |
377 | 9 | fixtures = ['test'] | 34 | fixtures = ['test'] |
378 | 10 | 35 | ||
379 | @@ -13,6 +38,13 @@ | |||
380 | 13 | email = EmailAddress.objects.get(email='test@canonical.com') | 38 | email = EmailAddress.objects.get(email='test@canonical.com') |
381 | 14 | self.assertEqual(account, email.account) | 39 | self.assertEqual(account, email.account) |
382 | 15 | 40 | ||
383 | 41 | def test_emailaddress_is_verifiable(self): | ||
384 | 42 | email = EmailAddress.objects.get(email='test@canonical.com') | ||
385 | 43 | self.assertTrue(email.is_verifiable()) | ||
386 | 44 | unverifiable_email = 'test@%s' % PHONE_EMAIL_DOMAIN | ||
387 | 45 | email = EmailAddress(email=unverifiable_email) | ||
388 | 46 | self.assertFalse(email.is_verifiable()) | ||
389 | 47 | |||
390 | 16 | def test_emailaddress_is_verified(self): | 48 | def test_emailaddress_is_verified(self): |
391 | 17 | email = EmailAddress.objects.get(email='test@canonical.com') | 49 | email = EmailAddress.objects.get(email='test@canonical.com') |
392 | 18 | assert email.status == EmailStatus.PREFERRED | 50 | assert email.status == EmailStatus.PREFERRED |
393 | 19 | 51 | ||
394 | === modified file 'webui/templates/account/emails.html' | |||
395 | --- webui/templates/account/emails.html 2012-12-19 18:54:44 +0000 | |||
396 | +++ webui/templates/account/emails.html 2013-02-18 16:43:22 +0000 | |||
397 | @@ -69,7 +69,9 @@ | |||
398 | 69 | <td class="email">{{ email }}</td> | 69 | <td class="email">{{ email }}</td> |
399 | 70 | {% if not readonly %} | 70 | {% if not readonly %} |
400 | 71 | <td class="actions"> | 71 | <td class="actions"> |
401 | 72 | {% if email.is_verifiable %} | ||
402 | 72 | <a href="./+verify-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Verify" %}</span></a> | 73 | <a href="./+verify-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Verify" %}</span></a> |
403 | 74 | {% endif %} | ||
404 | 73 | <a href="./+remove-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Delete" %}</span></a> | 75 | <a href="./+remove-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Delete" %}</span></a> |
405 | 74 | </td> | 76 | </td> |
406 | 75 | {% endif %} | 77 | {% endif %} |
407 | 76 | 78 | ||
408 | === modified file 'webui/tests/test_views_account.py' | |||
409 | --- webui/tests/test_views_account.py 2013-01-25 20:40:15 +0000 | |||
410 | +++ webui/tests/test_views_account.py 2013-02-18 16:43:22 +0000 | |||
411 | @@ -36,6 +36,36 @@ | |||
412 | 36 | from identityprovider.utils import validate_launchpad_password | 36 | from identityprovider.utils import validate_launchpad_password |
413 | 37 | 37 | ||
414 | 38 | 38 | ||
415 | 39 | class AccountEmailsViewTestCase(AuthenticatedTestCase): | ||
416 | 40 | |||
417 | 41 | def setUp(self): | ||
418 | 42 | super(AccountEmailsViewTestCase, self).setUp() | ||
419 | 43 | account = Account.objects.get(emailaddress__email=self.login_email) | ||
420 | 44 | self.phone_email = EmailAddress.objects.create_from_phone_id( | ||
421 | 45 | 'tel:+1234567890', account) | ||
422 | 46 | |||
423 | 47 | def test_phone_id_email_is_not_verifiable(self): | ||
424 | 48 | url = reverse('account-emails') | ||
425 | 49 | response = self.client.get(url) | ||
426 | 50 | tree = PyQuery(response.content) | ||
427 | 51 | # unverified emails -> second listing table | ||
428 | 52 | unverified_emails = tree.find('table.listing')[1] | ||
429 | 53 | emails_td = PyQuery(unverified_emails).find('td.email') | ||
430 | 54 | for email_td in emails_td: | ||
431 | 55 | if email_td.text == self.phone_email.email: | ||
432 | 56 | # get available actions | ||
433 | 57 | actions = email_td.getnext().getchildren() | ||
434 | 58 | # only delete option available, is not verifiable | ||
435 | 59 | self.assertEqual(len(actions), 1) | ||
436 | 60 | self.assertEqual(actions[0].get('href'), | ||
437 | 61 | '.%s?id=%d' % (reverse('delete_email'), | ||
438 | 62 | self.phone_email.id)) | ||
439 | 63 | else: | ||
440 | 64 | # get available actions: verify, remove | ||
441 | 65 | actions = email_td.getnext().getchildren() | ||
442 | 66 | self.assertEqual(len(actions), 2) | ||
443 | 67 | |||
444 | 68 | |||
445 | 39 | class AccountViewsUnauthenticatedTestCase(SSOBaseTestCase): | 69 | class AccountViewsUnauthenticatedTestCase(SSOBaseTestCase): |
446 | 40 | 70 | ||
447 | 41 | def test_index_unauthenticated(self): | 71 | def test_index_unauthenticated(self): |
Looks great, thanks!