diff --git a/django/forms/fields.py b/django/forms/fields.py index b07ebe84ee..e995187682 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -955,15 +955,20 @@ class MultiValueField(Field): """ default_error_messages = { 'invalid': _('Enter a list of values.'), + 'incomplete': _('Enter a complete value.'), } def __init__(self, fields=(), *args, **kwargs): + self.require_all_fields = kwargs.pop('require_all_fields', True) super(MultiValueField, self).__init__(*args, **kwargs) - # Set 'required' to False on the individual fields, because the - # required validation will be handled by MultiValueField, not by those - # individual fields. for f in fields: - f.required = False + f.error_messages.setdefault('incomplete', + self.error_messages['incomplete']) + if self.require_all_fields: + # Set 'required' to False on the individual fields, because the + # required validation will be handled by MultiValueField, not + # by those individual fields. + f.required = False self.fields = fields def validate(self, value): @@ -993,15 +998,26 @@ class MultiValueField(Field): field_value = value[i] except IndexError: field_value = None - if self.required and field_value in self.empty_values: - raise ValidationError(self.error_messages['required'], code='required') + if field_value in self.empty_values: + if self.require_all_fields: + # Raise a 'required' error if the MultiValueField is + # required and any field is empty. + if self.required: + raise ValidationError(self.error_messages['required'], code='required') + elif field.required: + # Otherwise, add an 'incomplete' error to the list of + # collected errors and skip field cleaning, if a required + # field is empty. + if field.error_messages['incomplete'] not in errors: + errors.append(field.error_messages['incomplete']) + continue try: clean_data.append(field.clean(field_value)) except ValidationError as e: # Collect all validation errors in a single list, which we'll # raise at the end of clean(), rather than raising a single - # exception for the first error we encounter. - errors.extend(e.error_list) + # exception for the first error we encounter. Skip duplicates. + errors.extend(m for m in e.error_list if m not in errors) if errors: raise ValidationError(errors) diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index ef4ed729bd..e7c6612a72 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -877,7 +877,7 @@ Slightly complex built-in ``Field`` classes * Normalizes to: the type returned by the ``compress`` method of the subclass. * Validates that the given value against each of the fields specified as an argument to the ``MultiValueField``. - * Error message keys: ``required``, ``invalid`` + * Error message keys: ``required``, ``invalid``, ``incomplete`` Aggregates the logic of multiple fields that together produce a single value. @@ -898,6 +898,45 @@ Slightly complex built-in ``Field`` classes Once all fields are cleaned, the list of clean values is combined into a single value by :meth:`~MultiValueField.compress`. + Also takes one extra optional argument: + + .. attribute:: require_all_fields + + .. versionadded:: 1.7 + + Defaults to ``True``, in which case a ``required`` validation error + will be raised if no value is supplied for any field. + + When set to ``False``, the :attr:`Field.required` attribute can be set + to ``False`` for individual fields to make them optional. If no value + is supplied for a required field, an ``incomplete`` validation error + will be raised. + + A default ``incomplete`` error message can be defined on the + :class:`MultiValueField` subclass, or different messages can be defined + on each individual field. For example:: + + from django.core.validators import RegexValidator + + class PhoneField(MultiValueField): + def __init__(self, *args, **kwargs): + # Define one message for all fields. + error_messages = { + 'incomplete': 'Enter a country code and phone number.', + } + # Or define a different message for each field. + fields = ( + CharField(error_messages={'incomplete': 'Enter a country code.'}, + validators=[RegexValidator(r'^\d+$', 'Enter a valid country code.')]), + CharField(error_messages={'incomplete': 'Enter a phone number.'}, + validators=[RegexValidator(r'^\d+$', 'Enter a valid phone number.')]), + CharField(validators=[RegexValidator(r'^\d+$', 'Enter a valid extension.')], + required=False), + ) + super(PhoneField, self).__init__( + self, error_messages=error_messages, fields=fields, + require_all_fields=False, *args, **kwargs) + .. attribute:: MultiValueField.widget Must be a subclass of :class:`django.forms.MultiWidget`. diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 161ae55436..fbbf93f627 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -122,6 +122,11 @@ Minor features ``html_email_template_name`` parameter used to send a multipart HTML email for password resets. +* :class:`~django.forms.MultiValueField` allows optional subfields by setting + the ``require_all_fields`` argument to ``False``. The ``required`` attribute + for each individual field will be respected, and a new ``incomplete`` + validation error will be raised when any required fields are empty. + Backwards incompatible changes in 1.7 ===================================== diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index 89c19cc5a4..1c72d17abe 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/tests/forms_tests/tests/test_forms.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import datetime from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.validators import RegexValidator from django.forms import * from django.http import QueryDict from django.template import Template, Context @@ -1792,6 +1793,75 @@ class FormsTestCase(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data, {'name' : 'fname lname'}) + def test_multivalue_optional_subfields(self): + class PhoneField(MultiValueField): + def __init__(self, *args, **kwargs): + fields = ( + CharField(label='Country Code', validators=[ + RegexValidator(r'^\+\d{1,2}$', message='Enter a valid country code.')]), + CharField(label='Phone Number'), + CharField(label='Extension', error_messages={'incomplete': 'Enter an extension.'}), + CharField(label='Label', required=False, help_text='E.g. home, work.'), + ) + super(PhoneField, self).__init__(fields, *args, **kwargs) + + def compress(self, data_list): + if data_list: + return '%s.%s ext. %s (label: %s)' % tuple(data_list) + return None + + # An empty value for any field will raise a `required` error on a + # required `MultiValueField`. + f = PhoneField() + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '') + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, []) + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, ['+61']) + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, ['+61', '287654321', '123']) + self.assertEqual('+61.287654321 ext. 123 (label: Home)', f.clean(['+61', '287654321', '123', 'Home'])) + self.assertRaisesMessage(ValidationError, + "'Enter a valid country code.'", f.clean, ['61', '287654321', '123', 'Home']) + + # Empty values for fields will NOT raise a `required` error on an + # optional `MultiValueField` + f = PhoneField(required=False) + self.assertEqual(None, f.clean('')) + self.assertEqual(None, f.clean(None)) + self.assertEqual(None, f.clean([])) + self.assertEqual('+61. ext. (label: )', f.clean(['+61'])) + self.assertEqual('+61.287654321 ext. 123 (label: )', f.clean(['+61', '287654321', '123'])) + self.assertEqual('+61.287654321 ext. 123 (label: Home)', f.clean(['+61', '287654321', '123', 'Home'])) + self.assertRaisesMessage(ValidationError, + "'Enter a valid country code.'", f.clean, ['61', '287654321', '123', 'Home']) + + # For a required `MultiValueField` with `require_all_fields=False`, a + # `required` error will only be raised if all fields are empty. Fields + # can individually be required or optional. An empty value for any + # required field will raise an `incomplete` error. + f = PhoneField(require_all_fields=False) + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, '') + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, None) + self.assertRaisesMessage(ValidationError, "'This field is required.'", f.clean, []) + self.assertRaisesMessage(ValidationError, "'Enter a complete value.'", f.clean, ['+61']) + self.assertEqual('+61.287654321 ext. 123 (label: )', f.clean(['+61', '287654321', '123'])) + six.assertRaisesRegex(self, ValidationError, + "'Enter a complete value\.', u?'Enter an extension\.'", f.clean, ['', '', '', 'Home']) + self.assertRaisesMessage(ValidationError, + "'Enter a valid country code.'", f.clean, ['61', '287654321', '123', 'Home']) + + # For an optional `MultiValueField` with `require_all_fields=False`, we + # don't get any `required` error but we still get `incomplete` errors. + f = PhoneField(required=False, require_all_fields=False) + self.assertEqual(None, f.clean('')) + self.assertEqual(None, f.clean(None)) + self.assertEqual(None, f.clean([])) + self.assertRaisesMessage(ValidationError, "'Enter a complete value.'", f.clean, ['+61']) + self.assertEqual('+61.287654321 ext. 123 (label: )', f.clean(['+61', '287654321', '123'])) + six.assertRaisesRegex(self, ValidationError, + "'Enter a complete value\.', u?'Enter an extension\.'", f.clean, ['', '', '', 'Home']) + self.assertRaisesMessage(ValidationError, + "'Enter a valid country code.'", f.clean, ['61', '287654321', '123', 'Home']) + def test_custom_empty_values(self): """ Test that form fields can customize what is considered as an empty value