Skip to content

Commit ae795df

Browse files
jpaniagualaconichclaudep
authored andcommitted
use registry to determine available phone methods (fixes #665)
1 parent e21e9d7 commit ae795df

File tree

7 files changed

+83
-76
lines changed

7 files changed

+83
-76
lines changed

tests/test_utils.py

+23-3
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
from phonenumber_field.phonenumber import PhoneNumber
88

99
from two_factor.plugins.email.utils import mask_email
10+
from two_factor.plugins.phonenumber.method import PhoneCallMethod, SMSMethod
1011
from two_factor.plugins.phonenumber.models import PhoneDevice
1112
from two_factor.plugins.phonenumber.utils import (
12-
backup_phones, format_phone_number, mask_phone_number,
13+
backup_phones, format_phone_number, get_available_phone_methods,
14+
mask_phone_number,
1315
)
16+
from two_factor.plugins.registry import GeneratorMethod, MethodRegistry
1417
from two_factor.utils import (
1518
USER_DEFAULT_DEVICE_ATTR_NAME, default_device, get_otpauth_url,
1619
totp_digits,
@@ -137,11 +140,28 @@ def test_wrong_device_hash(self):
137140

138141

139142
class PhoneUtilsTests(UserMixin, TestCase):
143+
def test_get_available_phone_methods(self):
144+
parameters = [
145+
# registered_methods, expected_codes
146+
([GeneratorMethod()], set()),
147+
([GeneratorMethod(), PhoneCallMethod()], {'call'}),
148+
([GeneratorMethod(), PhoneCallMethod(), SMSMethod()], {'call', 'sms'}),
149+
]
150+
with mock.patch('two_factor.plugins.phonenumber.utils.registry', new_callable=MethodRegistry) as test_registry:
151+
for registered_methods, expected_codes in parameters:
152+
with self.subTest(
153+
registered_methods=registered_methods,
154+
expected_codes=expected_codes,
155+
):
156+
test_registry._methods = registered_methods
157+
codes = {method.code for method in get_available_phone_methods()}
158+
self.assertEqual(codes, expected_codes)
159+
140160
def test_backup_phones(self):
141161
gateway = 'two_factor.gateways.fake.Fake'
142162
user = self.create_user()
143-
user.phonedevice_set.create(name='default', number='+12024561111')
144-
backup = user.phonedevice_set.create(name='backup', number='+12024561111')
163+
user.phonedevice_set.create(name='default', number='+12024561111', method='call')
164+
backup = user.phonedevice_set.create(name='backup', number='+12024561111', method='call')
145165

146166
parameters = [
147167
# with_gateway, with_user, expected_output

tests/test_views_phone.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,13 @@ def setUp(self):
161161
self.default = self.user.phonedevice_set.create(name='default', method='call', number='+12024561111')
162162
self.login_user()
163163

164+
@override_settings(TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake')
164165
def test_delete(self):
166+
self.assertEqual(len(backup_phones(self.user)), 1)
165167
response = self.client.post(reverse('two_factor:phone_delete',
166168
args=[self.backup.pk]))
167169
self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL))
168-
self.assertEqual(list(backup_phones(self.user)), [])
170+
self.assertEqual(backup_phones(self.user), [])
169171

170172
def test_cannot_delete_default(self):
171173
response = self.client.post(reverse('two_factor:phone_delete',

tests/test_views_profile.py

+22-40
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,42 @@
22
from django.test import TestCase, override_settings
33
from django.urls import reverse
44

5+
from two_factor.plugins.registry import registry
6+
57
from .utils import UserMixin
68

79

10+
@override_settings(
11+
TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake',
12+
TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake',
13+
)
814
class ProfileTest(UserMixin, TestCase):
9-
PHONENUMBER_PLUGIN_NAME = 'two_factor.plugins.phonenumber'
10-
EXPECTED_BASE_CONTEXT_KEYS = {
11-
'default_device',
12-
'default_device_type',
13-
'backup_tokens',
14-
}
15-
EXPECTED_PHONENUMBER_PLUGIN_ADDITIONAL_KEYS = {
16-
'backup_phones',
17-
'available_phone_methods',
18-
}
19-
2015
def setUp(self):
2116
super().setUp()
2217
self.user = self.create_user()
2318
self.enable_otp()
2419
self.login_user()
2520

26-
@classmethod
27-
def get_installed_apps_list(cls, with_phone_number_plugin=True):
28-
apps = set(settings.INSTALLED_APPS)
29-
if with_phone_number_plugin:
30-
apps.add(cls.PHONENUMBER_PLUGIN_NAME)
31-
else:
32-
apps.remove(cls.PHONENUMBER_PLUGIN_NAME)
33-
return list(apps)
34-
3521
def get_profile(self):
3622
url = reverse('two_factor:profile')
3723
return self.client.get(url)
3824

39-
def test_get_profile_without_phonenumer_plugin_enabled(self):
40-
apps_list = self.get_installed_apps_list(with_phone_number_plugin=False)
41-
with override_settings(INSTALLED_APPS=apps_list):
25+
def test_get_profile_without_phonenumber_plugin_enabled(self):
26+
without_phonenumber_plugin = [
27+
app for app in settings.INSTALLED_APPS if app != 'two_factor.plugins.phonenumber']
28+
29+
with override_settings(INSTALLED_APPS=without_phonenumber_plugin):
30+
self.assertFalse(registry.get_method('call'))
31+
self.assertFalse(registry.get_method('sms'))
32+
4233
response = self.get_profile()
43-
context_keys = set(response.context.keys())
44-
self.assertTrue(self.EXPECTED_BASE_CONTEXT_KEYS.issubset(context_keys))
45-
# None of the phonenumber related keys are present
46-
self.assertTrue(
47-
self.EXPECTED_PHONENUMBER_PLUGIN_ADDITIONAL_KEYS.isdisjoint(
48-
context_keys
49-
)
50-
)
34+
35+
self.assertTrue(response.context['available_phone_methods'] == [])
5136

5237
def test_get_profile_with_phonenumer_plugin_enabled(self):
53-
apps_list = self.get_installed_apps_list(with_phone_number_plugin=True)
54-
with override_settings(INSTALLED_APPS=apps_list):
55-
response = self.get_profile()
56-
context_keys = set(response.context.keys())
57-
expected_keys = (
58-
self.EXPECTED_BASE_CONTEXT_KEYS
59-
| self.EXPECTED_PHONENUMBER_PLUGIN_ADDITIONAL_KEYS
60-
)
61-
self.assertTrue(expected_keys.issubset(context_keys))
38+
self.assertTrue(registry.get_method('call'))
39+
self.assertTrue(registry.get_method('sms'))
40+
41+
response = self.get_profile()
42+
available_phone_method_codes = {method.code for method in response.context['available_phone_methods']}
43+
self.assertTrue(available_phone_method_codes == {'call', 'sms'})

two_factor/plugins/phonenumber/apps.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ class TwoFactorPhoneNumberConfig(AppConfig):
1212
url_prefix = 'phone'
1313

1414
def ready(self):
15-
updated_registered_methods(self, None, None)
16-
setting_changed.connect(updated_registered_methods)
15+
update_registered_methods(self, None, None)
16+
setting_changed.connect(update_registered_methods)
1717

1818

19-
def updated_registered_methods(sender, setting, value, **kwargs):
19+
def update_registered_methods(sender, setting, value, **kwargs):
2020
# This allows for dynamic registration, typically when testing.
2121
from .method import PhoneCallMethod, SMSMethod
2222

two_factor/plugins/phonenumber/forms.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ class Meta:
1515
model = PhoneDevice
1616
fields = ['number', 'method']
1717

18+
@staticmethod
19+
def get_available_choices():
20+
choices = []
21+
for method in get_available_phone_methods():
22+
choices.append((method.code, method.verbose_name))
23+
return choices
24+
1825
def __init__(self, **kwargs):
1926
super().__init__(**kwargs)
20-
self.fields['method'].choices = get_available_phone_methods()
27+
self.fields['method'].choices = self.get_available_choices()
2128

2229

2330
class PhoneNumberForm(forms.ModelForm):

two_factor/plugins/phonenumber/utils.py

+17-18
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,32 @@
11
import re
22

33
import phonenumbers
4-
from django.conf import settings
5-
from django.utils.translation import gettext_lazy as _
64

7-
phone_mask = re.compile(r'(?<=.{3})[0-9](?=.{2})')
8-
9-
10-
def backup_phones(user):
11-
no_gateways = (
12-
getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None) is None
13-
and getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None) is None)
14-
no_user = not user or user.is_anonymous
5+
from two_factor.plugins.registry import registry
156

16-
if no_gateways or no_user:
17-
from .models import PhoneDevice
18-
return PhoneDevice.objects.none()
19-
return user.phonedevice_set.filter(name='backup')
7+
phone_mask = re.compile(r'(?<=.{3})[0-9](?=.{2})')
208

219

2210
def get_available_phone_methods():
2311
methods = []
24-
if getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None):
25-
methods.append(('call', _('Phone call')))
26-
if getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None):
27-
methods.append(('sms', _('Text message')))
12+
for code in ['sms', 'call']:
13+
if method := registry.get_method(code):
14+
methods.append(method)
15+
2816
return methods
2917

3018

19+
def backup_phones(user):
20+
if not user or user.is_anonymous:
21+
return []
22+
23+
phones = []
24+
for method in get_available_phone_methods():
25+
phones += list(method.get_devices(user))
26+
27+
return [phone for phone in phones if phone.name == 'backup']
28+
29+
3130
def mask_phone_number(number):
3231
"""
3332
Masks a phone number, only first 3 and last 2 digits visible.

two_factor/views/profile.py

+7-10
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from django.apps.registry import apps
21
from django.conf import settings
32
from django.contrib.auth.decorators import login_required
43
from django.shortcuts import redirect, resolve_url
@@ -30,24 +29,22 @@ class ProfileView(TemplateView):
3029
template_name = 'two_factor/profile/profile.html'
3130

3231
def get_context_data(self, **kwargs):
32+
user = self.request.user
33+
3334
try:
34-
backup_tokens = self.request.user.staticdevice_set.all()[0].token_set.count()
35+
backup_tokens = user.staticdevice_set.all()[0].token_set.count()
3536

3637
except Exception:
3738
backup_tokens = 0
3839

3940
context = {
40-
'default_device': default_device(self.request.user),
41-
'default_device_type': default_device(self.request.user).__class__.__name__,
41+
'default_device': default_device(user),
42+
'default_device_type': default_device(user).__class__.__name__,
4243
'backup_tokens': backup_tokens,
44+
'backup_phones': backup_phones(user),
45+
'available_phone_methods': get_available_phone_methods(),
4346
}
4447

45-
if (apps.is_installed('two_factor.plugins.phonenumber')):
46-
context.update({
47-
'backup_phones': backup_phones(self.request.user),
48-
'available_phone_methods': get_available_phone_methods(),
49-
})
50-
5148
return context
5249

5350

0 commit comments

Comments
 (0)