diff --git a/docs/reference.rst b/docs/reference.rst index 6f597abc..15ade210 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -8,6 +8,12 @@ def print_html(html): print(BeautifulSoup(html).prettify().strip()) + def print_html_compact(html): + output = BeautifulSoup(html).prettify().strip().splitlines() + print("\n".join(output[:15])) + print(" ...") + print("\n".join(output[-7:])) + The PhoneNumber wrapper ======================= @@ -85,8 +91,11 @@ Usage # An optional phone number. phone_number = PhoneNumberField(blank=True) -Form field -========== +Form fields +=========== + +:class:`~phonenumber_field.formfields.PhoneNumberField` +------------------------------------------------------- The :class:`~phonenumber_field.formfields.PhoneNumberField` :ref:`form field ` to validate @@ -104,7 +113,7 @@ The :class:`~phonenumber_field.formfields.PhoneNumberField` :ref:`form field .. automethod:: __init__ Usage ------ +~~~~~ .. doctest:: formfield @@ -151,6 +160,127 @@ Usage international example phone number in the E.164 format is suggested. +:class:`~phonenumber_field.formfields.SplitPhoneNumberField` +------------------------------------------------------------ + +A :class:`~django.forms.MultiValueField` that offers: + +- a ```` to enter the phone number. + +This widget uses an example phone number from the selected region for the +``invalid`` key in :attr:`~django.forms.Field.error_messages`, when the region +choice is valid. + +To customize the dynamic message, use +:func:`phonenumber_field.formfields.SplitPhoneNumberField.invalid_error_message`. + +.. important:: Requires the `Babel `_ package. + +.. autoclass:: phonenumber_field.formfields.SplitPhoneNumberField + + .. automethod:: __init__ + .. automethod:: invalid_error_message + +Usage +~~~~~ + +.. doctest:: SplitPhoneNumberField + + >>> from django import forms + >>> from phonenumber_field.formfields import SplitPhoneNumberField + + >>> class PhoneForm(forms.Form): + ... number = SplitPhoneNumberField() + ... + + >>> form = PhoneForm() + >>> print_html_compact(form.as_div()) +
+
+ + Number: + + + +
+
+ + # Limiting country choices. + >>> class DemoSplitPhoneNumberField(SplitPhoneNumberField): + ... def prefix_field(self): + ... return forms.ChoiceField(choices=[ + ... ("", "---------"), + ... ("CA", "Canada"), + ... ("FR", "France"), + ... ]) + ... + >>> class LimitedCountryPhoneForm(forms.Form): + ... number = DemoSplitPhoneNumberField() + ... + >>> form = LimitedCountryPhoneForm() + >>> print_html(form.as_div()) +
+
+ + Number: + + + +
+
+ + # Pre-selecting a country. + >>> class FrenchPhoneForm(forms.Form): + ... number = DemoSplitPhoneNumberField(region="FR") + ... + + >>> form = FrenchPhoneForm() + >>> print_html(form.as_div()) +
+
+ + Number: + + + +
+
+ Widgets ------- @@ -209,89 +339,8 @@ Usage PhoneNumberPrefixWidget ~~~~~~~~~~~~~~~~~~~~~~~ -.. important:: Requires the `Babel `_ package - be installed. - .. autoclass:: phonenumber_field.widgets.PhoneNumberPrefixWidget - .. automethod:: __init__ - -Usage -..... - -.. doctest:: prefixwidget - - >>> from django import forms - >>> from phonenumber_field.formfields import PhoneNumberField - >>> from phonenumber_field.widgets import PhoneNumberPrefixWidget - - # Limiting country choices. - >>> class CanadianPhoneForm(forms.Form): - ... # RegionalPhoneNumberWidget is the default widget. - ... number = PhoneNumberField( - ... region="CA", - ... widget=PhoneNumberPrefixWidget( - ... country_choices=[ - ... ("CA", "Canada"), - ... ("FR", "France"), - ... ], - ... ), - ... ) - ... - - >>> form = CanadianPhoneForm({"number_0": "CA", "number_1": "6044011234"}) - >>> print_html(form.as_div()) -
-
- - Number: - - - -
-
- - # Pre-selecting a country. - >>> class FrenchPhoneForm(forms.Form): - ... # RegionalPhoneNumberWidget is the default widget. - ... number = PhoneNumberField( - ... region="FR", - ... widget=PhoneNumberPrefixWidget( - ... initial="FR", - ... country_choices=[ - ... ("CA", "Canada"), - ... ("FR", "France"), - ... ], - ... ), - ... ) - ... - - >>> form = FrenchPhoneForm() - >>> print_html(form.as_div()) -
-
- - Number: - - - -
-
- Serializer field ================ @@ -350,7 +399,7 @@ Validates: .. autofunction:: phonenumber_field.validators.validate_international_phonenumber -**code**: ``"invalid_phone_number"`` +**code**: ``"invalid"`` Settings ======== diff --git a/phonenumber_field/formfields.py b/phonenumber_field/formfields.py index 979f6536..aedc98df 100644 --- a/phonenumber_field/formfields.py +++ b/phonenumber_field/formfields.py @@ -1,19 +1,33 @@ import phonenumbers from django.conf import settings from django.core import validators -from django.core.exceptions import ValidationError -from django.forms.fields import CharField +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.forms.fields import CharField, ChoiceField, MultiValueField +from django.utils import translation from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ +from phonenumbers import COUNTRY_CODE_TO_REGION_CODE +from phonenumber_field import widgets from phonenumber_field.phonenumber import to_python, validate_region from phonenumber_field.validators import validate_international_phonenumber -from phonenumber_field.widgets import RegionalPhoneNumberWidget + +try: + import babel +except ModuleNotFoundError: + babel = None # type: ignore + +# 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 +} class PhoneNumberField(CharField): default_validators = [validate_international_phonenumber] - widget = RegionalPhoneNumberWidget + widget = widgets.RegionalPhoneNumberWidget def __init__(self, *args, region=None, widget=None, **kwargs): """ @@ -52,12 +66,113 @@ def __init__(self, *args, region=None, widget=None, **kwargs): ) def to_python(self, value): - phone_number = to_python(value, region=self.region) - - if phone_number in validators.EMPTY_VALUES: + if value in validators.EMPTY_VALUES: return self.empty_value + return to_python(value, region=self.region) + + +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 - if phone_number and not phone_number.is_valid(): - raise ValidationError(self.error_messages["invalid"]) - return phone_number +class PrefixChoiceField(ChoiceField): + def __init__(self, *, choices=None, **kwargs): + if choices is None: + language = translation.get_language() or settings.LANGUAGE_CODE + choices = localized_choices(language) + choices.sort(key=lambda item: item[1]) + super().__init__(choices=choices, **kwargs) + + +class SplitPhoneNumberField(MultiValueField): + default_validators = [validate_international_phonenumber] + widget = widgets.PhoneNumberPrefixWidget + + def __init__(self, *, initial=None, region=None, widget=None, **kwargs): + """ + :keyword list initial: A two-elements iterable: + + #. the region code, an :class:`str`, the 2-letter country code + as defined in ISO 3166-1. + + #. the phone number, an :class:`str` + + When ``initial`` is not provided, the ``region`` keyword argument + is used as the initial for the region field if specified, otherwise + :setting:`PHONENUMBER_DEFAULT_REGION` is used. + + See :attr:`django.forms.Field.initial`. + :keyword str region: 2-letter country code as defined in ISO 3166-1. + When not supplied, defaults to :setting:`PHONENUMBER_DEFAULT_REGION` + :keyword ~django.forms.MultiWidget widget: defaults to + :class:`~phonenumber_field.widgets.PhoneNumberPrefixWidget` + """ + validate_region(region) + region = region or getattr(settings, "PHONENUMBER_DEFAULT_REGION", None) + if initial is None and region: + initial = [region, None] + prefix_field = self.prefix_field() + number_field = self.number_field() + fields = (prefix_field, number_field) + if widget is None: + widget = self.widget((prefix_field.widget, number_field.widget)) + super().__init__(fields, initial=initial, widget=widget, **kwargs) + + def prefix_field(self): + """ + Customize the phone number prefix field. + """ + return PrefixChoiceField() + + def number_field(self): + """ + Customize the phone number input field. + """ + number_field = CharField() + number_field.widget.input_type = "tel" + return number_field + + def invalid_error_message(self): + """ + Hook to customize ``error_messages["invalid"]`` for a given region. + + Include the example number in the message with the ``{example_number}`` + placeholder. + """ + # Translators: {example_number} is a national phone number. + return _("Enter a valid phone number (e.g. {example_number}).") + + def compress(self, data_list): + if not data_list: + return data_list + region, national_number = data_list + return to_python(national_number, region=region) + + def clean(self, value): + if not self.disabled: + prefix_field = self.fields[0] + try: + region = prefix_field.clean(value[0]) + except ValidationError: + pass # The parent class handles validation. + else: + if region: + number = phonenumbers.example_number(region) + example_number = to_python(number).as_national + error_message = self.invalid_error_message() + self.error_messages["invalid"] = format_lazy( + error_message, + example_number=example_number, + ) + return super().clean(value) diff --git a/phonenumber_field/validators.py b/phonenumber_field/validators.py index 3d9f5957..e4be52d5 100644 --- a/phonenumber_field/validators.py +++ b/phonenumber_field/validators.py @@ -8,5 +8,5 @@ def validate_international_phonenumber(value): phone_number = to_python(value) if isinstance(phone_number, PhoneNumber) and not phone_number.is_valid(): raise ValidationError( - _("The phone number entered is not valid."), code="invalid_phone_number" + _("The phone number entered is not valid."), code="invalid" ) diff --git a/phonenumber_field/widgets.py b/phonenumber_field/widgets.py index bd630e1a..a4a58054 100644 --- a/phonenumber_field/widgets.py +++ b/phonenumber_field/widgets.py @@ -1,11 +1,6 @@ from django.conf import settings -from django.core import validators -from django.core.exceptions import ImproperlyConfigured -from django.forms import Select, TextInput -from django.forms.widgets import MultiWidget -from django.utils import translation +from django.forms.widgets import MultiWidget, TextInput from phonenumbers import ( - COUNTRY_CODE_TO_REGION_CODE, national_significant_number, region_code_for_number, region_codes_for_country_code, @@ -13,119 +8,19 @@ from phonenumber_field.phonenumber import PhoneNumber, to_python -try: - import babel -except ModuleNotFoundError: - babel = None # type: ignore - -# 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, attrs=None, choices=None): - language = translation.get_language() or settings.LANGUAGE_CODE - if choices is None: - choices = localized_choices(language) - choices.sort(key=lambda item: item[1]) - if initial is None: - initial = getattr(settings, "PHONENUMBER_DEFAULT_REGION", None) - if initial in REGION_CODE_TO_COUNTRY_CODE: - self.initial = initial - super().__init__(attrs=attrs, choices=choices) - - def get_context(self, name, value, attrs): - attrs = (attrs or {}).copy() - attrs.pop("maxlength", None) - return super().get_context(name, value or self.initial, attrs) - class PhoneNumberPrefixWidget(MultiWidget): """ - A Widget that splits a phone number into fields: - - - a :class:`~django.forms.Select` for the country (phone prefix) - - a :class:`~django.forms.TextInput` for local phone number + Companion widget of :class:`~phonenumber_field.formfields.SplitPhoneNumberField`. """ - def __init__( - self, - attrs=None, - initial=None, - country_attrs=None, - country_choices=None, - number_attrs=None, - region=None, - ): - """ - :keyword dict attrs: See :attr:`~django.forms.Widget.attrs` - :keyword dict initial: See :attr:`~django.forms.Field.initial` - :keyword dict country_attrs: The :attr:`~django.forms.Widget.attrs` for - the country :class:`~django.forms.Select`. - :keyword country_choices: Limit the country choices. - :type country_choices: list of 2-tuple, optional - The first element is the value, which must match a country code - recognized by the phonenumbers project. - The second element is the label. - :keyword dict number_attrs: The :attr:`~django.forms.Widget.attrs` for - the local phone number :class:`~django.forms.TextInput`. - :keyword str region: 2-letter country code as defined in ISO 3166-1. - When not supplied, defaults to :setting:`PHONENUMBER_DEFAULT_REGION` - """ - widgets = ( - PhonePrefixSelect( - initial or region, - attrs=country_attrs, - choices=country_choices, - ), - TextInput(attrs=number_attrs), - ) - super().__init__(widgets, attrs) - def decompress(self, value): 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): - 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 number in validators.EMPTY_VALUES: - return number - number._region = region_code - return number - class RegionalPhoneNumberWidget(TextInput): """ @@ -147,17 +42,15 @@ def __init__(self, region=None, attrs=None): self.region = region super().__init__(attrs) - def value_from_datadict(self, data, files, name): - phone_number_str = super().value_from_datadict(data, files, name) - - if phone_number_str is None: - phone_number_str = "" - return to_python(phone_number_str, region=self.region) - def format_value(self, value): - if isinstance(value, PhoneNumber): - if value.is_valid() and value.country_code: - region_codes = region_codes_for_country_code(value.country_code) - if self.region in region_codes: - return value.as_national + number = value + if not isinstance(number, PhoneNumber): + try: + number = to_python(value, region=self.region) + except TypeError: + pass + if isinstance(number, PhoneNumber) and number.country_code: + region_codes = region_codes_for_country_code(number.country_code) + if self.region in region_codes: + return number.as_national return super().format_value(value) diff --git a/tests/test_formfields.py b/tests/test_formfields.py index 3656bac2..a27ce912 100644 --- a/tests/test_formfields.py +++ b/tests/test_formfields.py @@ -1,12 +1,15 @@ from unittest import mock import django +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 django.utils.functional import lazy -from phonenumber_field.formfields import PhoneNumberField -from phonenumber_field.widgets import PhoneNumberPrefixWidget +from phonenumber_field.formfields import PhoneNumberField, SplitPhoneNumberField +from phonenumber_field.phonenumber import PhoneNumber ALGERIAN_PHONE_NUMBER = "+213799136332" @@ -78,11 +81,385 @@ def fail_gettext(msgid): ): PhoneNumberField() + def test_override_widget(self): + class MyPhoneNumberField(PhoneNumberField): + widget = forms.TextInput + + class TestForm(forms.Form): + phone = MyPhoneNumberField(region="FR") + + form = TestForm({"phone": "+33612345678"}) + self.assertIs(form.is_valid(), True) + self.assertHTMLEqual( + form.as_p(), + """ +

+ + +

+ """, + ) + + @override_settings(PHONENUMBER_DEFAULT_REGION="FR") + def test_widget_uses_default_region(self): + class TestForm(forms.Form): + phone = PhoneNumberField() + + form = TestForm({"phone": "+33612345678"}) + self.assertIs(form.is_valid(), True) + self.assertHTMLEqual( + form.as_p(), + """ +

+ + +

+ """, + ) + + def test_invalid_phone_number(self): + class PhoneNumberForm(forms.Form): + number = PhoneNumberField() + + form = PhoneNumberForm({"number": "+11234567890"}) + self.assertIs(form.is_valid(), False) + self.assertEqual( + form.errors, {"number": ["Enter a valid phone number (e.g. +12125552368)."]} + ) + + +class SplitPhoneNumberFormFieldTest(SimpleTestCase): + def example_number(self, region_code: str) -> PhoneNumber: + number = phonenumbers.example_number(region_code) + assert number is not None + e164 = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) + return PhoneNumber.from_string(e164, region=region_code) + + def test_initial_empty(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField(region="CN") + + rendered = str(TestForm()) + self.assertIn('', rendered) + self.assertIn('', rendered) + + @override_settings(PHONENUMBER_DEFAULT_REGION="CN") + def test_uses_default_region_as_initial(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + rendered = str(TestForm()) + self.assertIn('', rendered) + self.assertIn('', rendered) + + def test_no_initial(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + rendered = str(TestForm()) + self.assertIn('', rendered) + self.assertIn('', rendered) + + def test_initial(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + rendered = str( + TestForm(initial={"phone": PhoneNumber.from_string("+33612345678")}) + ) + self.assertIn('', rendered) + self.assertIn('', rendered) + self.assertIn( + '', + rendered, + ) + + def test_invalid_phone_number(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + form = TestForm({"phone_0": "US", "phone_1": "1234567890"}) + self.assertIs(form.is_valid(), False) + self.assertEqual( + form.errors, + {"phone": ["Enter a valid phone number (e.g. (201) 555-0123)."]}, + ) + + @override_settings(USE_I18N=True) + def test_after_translation_deactivate_all(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + translation.deactivate_all() + rendered = str(TestForm()) + self.assertIn( + '', + rendered_form, + count=1, + ) + self.assertInHTML( + '', + rendered_form, + count=1, + ) + + def test_empty_national_number(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + form = TestForm(data={"phone_0": "CA", "phone_1": ""}) + self.assertFalse(form.is_valid()) + rendered_form = form.as_ul() + self.assertInHTML( + '
  • This field is required.
', + rendered_form, + ) + aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' + self.assertInHTML( + f'', + rendered_form, + count=1, + ) + + def test_not_required_empty_data(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField(required=False) + + form = TestForm(data={"phone_0": "", "phone_1": ""}) + self.assertIs(form.is_valid(), True) + + def test_no_region(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + form = TestForm(data={"phone_1": "654321"}) + self.assertFalse(form.is_valid()) + rendered_form = form.as_ul() + self.assertInHTML( + '
  • This field is required.
', + rendered_form, + ) + aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' + self.assertInHTML( + f'', + rendered_form, + count=1, + ) + self.assertInHTML( + '', + rendered_form, + count=1, + ) + + def test_no_national_number(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + form = TestForm(data={"phone_0": "CA"}) + self.assertFalse(form.is_valid()) + rendered_form = form.as_ul() + self.assertInHTML( + '
  • This field is required.
', + rendered_form, + count=1, + ) + aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' + self.assertInHTML( + f'', + rendered_form, + count=1, + ) + + def test_not_required_no_data(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField(required=False) + + form = TestForm(data={}) + self.assertIs(form.is_valid(), True) + + def test_keeps_region_with_invalid_national_number(self): + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + form = TestForm(data={"phone_0": "CA", "phone_1": "0000"}) + self.assertFalse(form.is_valid()) + rendered_form = str(form) + self.assertInHTML( + '
    ' + "
  • Enter a valid phone number (e.g. (506) 234-5678).
", + rendered_form, + ) + aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' + self.assertInHTML( + f'', + rendered_form, + count=1, + ) + self.assertInHTML( + '', + rendered_form, + count=1, + ) + + def test_maxlength_not_in_select(self): + # Regression test for #490 + class TestForm(forms.Form): + phone = SplitPhoneNumberField() + + rendered = str(TestForm()) + self.assertIn('', + rendered, + ) + self.assertInHTML( + '', + rendered, + count=1, + ) + + def test_custom_choices(self): + class SplitPhoneNumberFieldWithCountries(SplitPhoneNumberField): + def prefix_field(self): + return forms.ChoiceField(choices=[("FR", "France"), ("BE", "Belgium")]) + + class TestForm(forms.Form): + phone = SplitPhoneNumberFieldWithCountries() + + form = TestForm() + if django.VERSION[0] < 4: + self.assertHTMLEqual( + str(form.as_p()), + """ +

+ + + +

+ """, + ) + else: + self.assertHTMLEqual( + form.as_div(), + """ +
+
+ + Phone: + + + +
+
+ """, + ) + + def test_invalid_region(self): + class PhoneNumberForm(forms.Form): + number = SplitPhoneNumberField() + + form = PhoneNumberForm({"number_0": "NONEXISTENT", "number_1": "+33612345678"}) + self.assertIs(form.is_valid(), False) + self.assertEqual( + form.errors, + { + "number": [ + "Select a valid choice. " + "NONEXISTENT is not one of the available choices." + ] + }, + ) + @override_settings(PHONENUMBER_DEFAULT_REGION="FR") def test_input_not_cleared_on_other_field_error(self): class PhoneNumberForm(forms.Form): name = forms.CharField(min_length=4, max_length=100) - number = PhoneNumberField(required=False, widget=PhoneNumberPrefixWidget()) + number = SplitPhoneNumberField(required=False) form = PhoneNumberForm({"name": "a", "number_0": "FR", "number_1": "612345678"}) self.assertIs(form.is_valid(), False) @@ -122,48 +499,84 @@ class PhoneNumberForm(forms.Form): count=1, ) self.assertInHTML( - '', + '', form_html, count=1, ) - def test_override_widget(self): - class MyPhoneNumberField(PhoneNumberField): - widget = forms.TextInput + def test_without_babel(self): + import phonenumber_field.formfields - class TestForm(forms.Form): - phone = MyPhoneNumberField(region="FR") + babel = phonenumber_field.formfields.babel - form = TestForm({"phone": "+33612345678"}) - self.assertIs(form.is_valid(), True) - self.assertHTMLEqual( - form.as_p(), - """ -

- - -

- """, + def restore_babel(): + phonenumber_field.formfields.babel = babel + + self.addCleanup(restore_babel) + phonenumber_field.formfields.babel = None + with self.assertRaises(ImproperlyConfigured): + SplitPhoneNumberField() + + def test_error_message_is_localized(self): + class PhoneNumberForm(forms.Form): + number = SplitPhoneNumberField() + + form = PhoneNumberForm({"number_0": "FR", "number_1": "1"}) + self.assertIn( + '
  • ' + "Enter a valid phone number (e.g. 01 23 45 67 89)." + "
", + str(form), ) - @override_settings(PHONENUMBER_DEFAULT_REGION="FR") - def test_widget_uses_default_region(self): + def test_customize_invalid_error_message(self): + class CustomSplitPhoneNumberField(SplitPhoneNumberField): + def invalid_error_message(self): + return "My message using {example_number}." + class TestForm(forms.Form): - phone = PhoneNumberField() + phone = CustomSplitPhoneNumberField() - form = TestForm({"phone": "+33612345678"}) - self.assertIs(form.is_valid(), True) - self.assertHTMLEqual( - form.as_p(), - """ -

- - -

- """, + form = TestForm({"phone_0": "FR", "phone_1": "1"}) + self.assertIn( + '
  • ' + "My message using 01 23 45 67 89." + "
", + str(form), ) + + def test_clean_handles_invalid_input(self): + data = [ + # require_all_fields, phone_data, error_message + (True, {"phone_0": "", "phone_1": "1234"}, "This field is required."), + (True, {}, "This field is required."), + (True, {"phone_1": "1234"}, "This field is required."), + ( + True, + {"phone_0": "invalid", "phone_1": "1234"}, + "Select a valid choice. invalid is not one of the available choices.", + ), + (False, {"phone_0": "", "phone_1": "1234"}, "Enter a complete value."), + (False, {}, "This field is required."), + (False, {"phone_1": "1234"}, "Enter a complete value."), + ( + False, + {"phone_0": "invalid", "phone_1": "1234"}, + "Select a valid choice. invalid is not one of the available choices.", + ), + ] + for all_fields_required, phone_data, error_message in data: + with self.subTest( + f"require_all_fields={all_fields_required}," + f"{phone_data=}," + f"{error_message=}" + ): + + class TestForm(forms.Form): + phone = SplitPhoneNumberField( + require_all_fields=all_fields_required + ) + + form = TestForm(phone_data) + self.assertIs(form.is_valid(), False) + self.assertEqual(form.errors["phone"], [error_message]) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index edea1a44..cb399056 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,255 +1,7 @@ -import django -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 formfields, widgets from phonenumber_field.phonenumber import PhoneNumber -from phonenumber_field.widgets import PhoneNumberPrefixWidget, RegionalPhoneNumberWidget - - -class PhonePrefixSelectTest(SimpleTestCase): - def setUp(self): - super().setUp() - self.babel_module = widgets.babel - - def test_without_babel(self): - widgets.babel = None - with self.assertRaises(ImproperlyConfigured): - widgets.PhonePrefixSelect() - - def tearDown(self): - widgets.babel = self.babel_module - super().tearDown() - - -def example_number(region_code: str) -> PhoneNumber: - number = phonenumbers.example_number(region_code) - assert number is not None - 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('', rendered) - self.assertIn('', rendered) - - @override_settings(PHONENUMBER_DEFAULT_REGION="CN") - def test_uses_default_region_as_initial(self): - rendered = PhoneNumberPrefixWidget().render("", "") - self.assertIn('', rendered) - self.assertIn('', rendered) - - def test_uses_kwarg_region_as_prefix(self): - rendered = PhoneNumberPrefixWidget(region="CN").render("", "") - self.assertIn('', rendered) - self.assertIn('', rendered) - - def test_no_initial(self): - rendered = PhoneNumberPrefixWidget().render("", "") - self.assertIn('', rendered) - self.assertIn('', rendered) - - @override_settings(USE_I18N=True) - def test_after_translation_deactivate_all(self): - translation.deactivate_all() - rendered = PhoneNumberPrefixWidget().render("", "") - self.assertIn( - '', - rendered_form, - count=1, - ) - self.assertInHTML( - '', - 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( - '
  • This field is required.
', - rendered_form, - ) - aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' - self.assertInHTML( - f'', - rendered_form, - count=1, - ) - - def test_not_required_empty_data(self): - class TestForm(forms.Form): - phone = formfields.PhoneNumberField( - required=False, widget=PhoneNumberPrefixWidget - ) - - form = TestForm(data={"phone_0": "", "phone_1": ""}) - self.assertIs(form.is_valid(), True) - - 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( - '
  • Enter a valid phone number ' - "(e.g. +12125552368).
", - rendered_form, - ) - aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' - self.assertInHTML( - f'', - rendered_form, - count=1, - ) - self.assertInHTML( - '', - 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( - '
  • This field is required.
', - rendered_form, - count=1, - ) - aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' - self.assertInHTML( - f'', - rendered_form, - count=1, - ) - - def test_not_required_no_data(self): - class TestForm(forms.Form): - phone = formfields.PhoneNumberField( - required=False, widget=PhoneNumberPrefixWidget - ) - - form = TestForm(data={}) - self.assertIs(form.is_valid(), True) - - 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( - '
  • Enter a valid phone number (e.g. +12125552368).' - "
", - rendered_form, - ) - aria_invalid = "" if django.VERSION[0] < 5 else 'aria-invalid="true" ' - self.assertInHTML( - f'', - rendered_form, - count=1, - ) - self.assertInHTML( - '', - rendered_form, - count=1, - ) - - def test_maxlength_not_in_select(self): - # Regression test for #490 - widget = PhoneNumberPrefixWidget() - html = widget.render(name="widget", value=None, attrs={"maxlength": 32}) - self.assertIn('', html) - self.assertInHTML( - '', html, count=1 - ) - - def test_custom_choices(self): - widget = PhoneNumberPrefixWidget( - country_choices=[("FR", "France"), ("BE", "Belgium")] - ) - self.assertHTMLEqual( - widget.render(name="widget", value=None), - """ - - - """, - ) +from phonenumber_field.widgets import RegionalPhoneNumberWidget class RegionalPhoneNumberWidgetTest(SimpleTestCase):