From 9923578b435392304c5ac8c56b1f7a8424647d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Freitag?= Date: Sun, 3 Mar 2024 18:58:22 +0100 Subject: [PATCH] Move validation from widgets to the form fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the widgets handled part of the validation. That behavior prevents overriding validation in form fields, as widgets were casting the value into a `PhoneNumber` object, validating it in the process. Following the `MultiValueField` implementation from Django (and `MultiWidget`), the widget now handles the presentation logic, but makes no attempt at validation. The new `SplitPhoneNumberField` handles the logic of validating the region choice and the number, and the `PhoneNumberPrefixWidget` simply dispatches the region and number data to the appropriate widget. In order to retain backward compatibility, now that the `validate_international_phonenumber` actually comes into play, it’s error code has been changed to `invalid`, so that the custom error message for invalid shows. Migration guide =============== `validate_international_phonenumber` ------------------------------------ Review uses of the `invalid_phone_number` error code. Given that the validator usually did not come into play, you shouldn’t find many uses. `PhoneNumberField` with `RegionalPhoneNumberWidget` --------------------------------------------------- Make sure custom validation occurs in the Django `Form` `clean_FIELD()` (or `clean()`), and no changes should be noticeable. `PhoneNumberField` with `PhoneNumberPrefixWidget` ------------------------------------------------- Use the `SplitPhoneNumberField` instead. Error messages will change slightly and should be more precise (whether the region is not part of the choices, or the number cannot be interpreted in the selected region). For more examples, take a look at `tests.test_formfields.SplitPhoneNumberFieldTest`. In particular `test_custom_attrs` and `test_custom_choices`, to see how they were migrated to the new field. --- docs/reference.rst | 219 ++++++++------ phonenumber_field/formfields.py | 135 ++++++++- phonenumber_field/validators.py | 2 +- phonenumber_field/widgets.py | 131 +-------- tests/test_formfields.py | 487 +++++++++++++++++++++++++++++--- tests/test_widgets.py | 250 +--------------- 6 files changed, 723 insertions(+), 501 deletions(-) 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):