Skip to content

Commit

Permalink
Handle regions sharing country code in PhoneNumberPrefixWidget
Browse files Browse the repository at this point in the history
Each choice should be indexed with a unique value, use the region ISO code
over the national prefix.

Fixes #133
Fixes #251
Fixes #489
  • Loading branch information
amateja authored and francoisfreitag committed Jun 14, 2022
1 parent 4435e86 commit 882cee6
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 37 deletions.
4 changes: 2 additions & 2 deletions phonenumber_field/validators.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

from phonenumber_field.phonenumber import to_python
from phonenumber_field.phonenumber import PhoneNumber, to_python


def validate_international_phonenumber(value):
phone_number = to_python(value)
if phone_number and not phone_number.is_valid():
if isinstance(phone_number, PhoneNumber) and not phone_number.is_valid():
raise ValidationError(
_("The phone number entered is not valid."), code="invalid_phone_number"
)
79 changes: 48 additions & 31 deletions phonenumber_field/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,47 @@
region_code_for_number,
)

from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.phonenumber import PhoneNumber, to_python

try:
import babel
except ModuleNotFoundError:
babel = None

# ISO 3166-1 alpha-2 to national prefix
REGION_CODE_TO_COUNTRY_CODE = {
region_code: country_code
for country_code, region_codes in COUNTRY_CODE_TO_REGION_CODE.items()
for region_code in region_codes
}


def localized_choices(language):
if babel is None:
raise ImproperlyConfigured(
"The PhonePrefixSelect widget requires the babel package be installed."
)

choices = [("", "---------")]
locale_name = translation.to_locale(language)
locale = babel.Locale(locale_name)
for region_code, country_code in REGION_CODE_TO_COUNTRY_CODE.items():
region_name = locale.territories.get(region_code)
if region_name:
choices.append((region_code, f"{region_name} +{country_code}"))
return choices


class PhonePrefixSelect(Select):
initial = None

def __init__(self, initial=None):
if babel is None:
raise ImproperlyConfigured(
"The PhonePrefixSelect widget requires the babel package be installed."
)

choices = [("", "---------")]
language = translation.get_language() or settings.LANGUAGE_CODE
locale = babel.Locale(translation.to_locale(language))
if not initial:
choices = localized_choices(language)
if initial is None:
initial = getattr(settings, "PHONENUMBER_DEFAULT_REGION", None)
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
prefix = "+%d" % prefix
if initial and initial in values:
self.initial = prefix
for country_code in values:
country_name = locale.territories.get(country_code)
if country_name:
choices.append((prefix, f"{country_name} {prefix}"))
if initial in REGION_CODE_TO_COUNTRY_CODE:
self.initial = initial
super().__init__(choices=sorted(choices, key=lambda item: item[1]))

def get_context(self, name, value, attrs):
Expand All @@ -60,22 +71,28 @@ def __init__(self, attrs=None, initial=None):
super().__init__(widgets, attrs)

def decompress(self, value):
if value:
if isinstance(value, PhoneNumber):
if value.country_code and value.national_number:
return [
"+%d" % value.country_code,
national_significant_number(value),
]
else:
return value.split(".")
return [None, ""]
if isinstance(value, PhoneNumber):
if not value.is_valid():
region = getattr(settings, "PHONENUMBER_DEFAULT_REGION", None)
region_code = getattr(value, "_region", region)
return [region_code, value.raw_input]
region_code = region_code_for_number(value)
national_number = national_significant_number(value)
return [region_code, national_number]
return [None, None]

def value_from_datadict(self, data, files, name):
values = super().value_from_datadict(data, files, name)
if all(values):
return "%s.%s" % tuple(values)
return ""
region_code, national_number = super().value_from_datadict(data, files, name)

if national_number is None:
national_number = ""
number = to_python(national_number, region=region_code)
if isinstance(number, str):
number = PhoneNumber()
number.raw_input = national_number
if not number.is_valid():
setattr(number, "_region", region_code)
return number


class PhoneNumberInternationalFallbackWidget(TextInput):
Expand Down
157 changes: 153 additions & 4 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import phonenumbers
from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase, override_settings
from django.utils import translation

from phonenumber_field import widgets
from phonenumber_field import formfields, widgets
from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.widgets import (
PhoneNumberInternationalFallbackWidget,
Expand All @@ -25,22 +27,47 @@ def tearDown(self):
super().tearDown()


def example_number(region_code: str) -> PhoneNumber:
number = phonenumbers.example_number(region_code)
e164 = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
return PhoneNumber.from_string(e164, region=region_code)


EXAMPLE_NUMBERS = (
example_number("US"),
example_number("CA"),
example_number("GB"),
example_number("GG"),
example_number("PL"),
example_number("IT"),
)


class PhoneNumberPrefixWidgetTest(SimpleTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()

class TestForm(forms.Form):
phone = formfields.PhoneNumberField(widget=PhoneNumberPrefixWidget)

cls.form_class = TestForm

def test_initial(self):
rendered = PhoneNumberPrefixWidget(initial="CN").render("", "")
self.assertIn('<option value="">---------</option>', rendered)
self.assertIn('<option value="+86" selected>China +86</option>', rendered)
self.assertIn('<option value="CN" selected>China +86</option>', rendered)

@override_settings(PHONENUMBER_DEFAULT_REGION="CN")
def test_uses_default_region_as_initial(self):
rendered = PhoneNumberPrefixWidget().render("", "")
self.assertIn('<option value="">---------</option>', rendered)
self.assertIn('<option value="+86" selected>China +86</option>', rendered)
self.assertIn('<option value="CN" selected>China +86</option>', rendered)

def test_no_initial(self):
rendered = PhoneNumberPrefixWidget().render("", "")
self.assertIn('<option value="" selected>---------</option>', rendered)
self.assertIn('<option value="+86">China +86</option>', rendered)
self.assertIn('<option value="CN">China +86</option>', rendered)

@override_settings(USE_I18N=True)
def test_after_translation_deactivate_all(self):
Expand All @@ -50,6 +77,128 @@ def test_after_translation_deactivate_all(self):
'<select name="_0"><option value="" selected>---------</option>', rendered
)

def test_value_from_datadict(self):
tests = [
(
{
"phone_0": phonenumbers.region_code_for_number(number),
"phone_1": phonenumbers.national_significant_number(number),
},
number,
)
for number in EXAMPLE_NUMBERS
]

for data, expected in tests:
with self.subTest(data):
form = self.form_class(data=data)
self.assertIs(form.is_valid(), True)
self.assertEqual(form.cleaned_data, {"phone": expected})

def test_empty_region(self):
invalid_national_number = "8876543210"
form = self.form_class(data={"phone_0": "", "phone_1": invalid_national_number})
self.assertFalse(form.is_valid())
rendered_form = form.as_ul()
self.assertInHTML(
'<ul class="errorlist"><li>Enter a valid phone number '
"(e.g. +12125552368).</li></ul>",
rendered_form,
)
# Keeps national number input.
self.assertInHTML(
f'<input type="text" name="phone_1" '
f'value="{invalid_national_number}" required id="id_phone_1">',
rendered_form,
count=1,
)
self.assertInHTML(
'<option value="" selected>---------</option>',
rendered_form,
count=1,
)

def test_empty_national_number(self):
form = self.form_class(data={"phone_0": "CA", "phone_1": ""})
self.assertFalse(form.is_valid())
rendered_form = form.as_ul()
self.assertInHTML(
'<ul class="errorlist"><li>The phone number entered is not valid.'
"</li></ul>",
rendered_form,
)
self.assertInHTML(
'<input type="text" name="phone_1" required id="id_phone_1">',
rendered_form,
count=1,
)
self.assertInHTML(
'<option value="CA" selected>Canada +1</option>',
rendered_form,
count=1,
)

def test_no_region(self):
form = self.form_class(data={"phone_1": "654321"})
self.assertFalse(form.is_valid())
rendered_form = form.as_ul()
self.assertInHTML(
'<ul class="errorlist"><li>Enter a valid phone number '
"(e.g. +12125552368).</li></ul>",
rendered_form,
)
self.assertInHTML(
'<input type="text" name="phone_1" value="654321" '
'required id="id_phone_1">',
rendered_form,
count=1,
)
self.assertInHTML(
'<option value="" selected>---------</option>',
rendered_form,
count=1,
)

def test_no_national_number(self):
form = self.form_class(data={"phone_0": "CA"})
self.assertFalse(form.is_valid())
rendered_form = form.as_ul()
self.assertInHTML(
'<ul class="errorlist"><li>The phone number entered is not valid.'
"</li></ul>",
rendered_form,
)
self.assertInHTML(
'<input type="text" name="phone_1" required id="id_phone_1">',
rendered_form,
count=1,
)
self.assertInHTML(
'<option value="CA" selected>Canada +1</option>',
rendered_form,
count=1,
)

def test_keeps_region_with_invalid_national_number(self):
form = self.form_class(data={"phone_0": "CA", "phone_1": "0000"})
self.assertFalse(form.is_valid())
rendered_form = str(form)
self.assertInHTML(
'<ul class="errorlist"><li>Enter a valid phone number (e.g. +12125552368).'
"</li></ul>",
rendered_form,
)
self.assertInHTML(
'<input type="text" name="phone_1" value="0000" required id="id_phone_1">',
rendered_form,
count=1,
)
self.assertInHTML(
'<option value="CA" selected>Canada +1</option>',
rendered_form,
count=1,
)

def test_maxlength_not_in_select(self):
# Regression test for #490
widget = PhoneNumberPrefixWidget()
Expand Down

0 comments on commit 882cee6

Please sign in to comment.