Commit 12806758 authored by Tai Lee's avatar Tai Lee Committed by Tim Graham
Browse files

Fixed #15511 -- Allow optional fields on ``MultiValueField` subclasses.

The `MultiValueField` class gets a new ``require_all_fields`` argument that
defaults to ``True``. If set to ``False``, individual fields can be made
optional, and a new ``incomplete`` validation error will be raised if any
required fields have empty values.

The ``incomplete`` error message can be defined on a `MultiValueField`
subclass or on each individual field. Skip duplicate errors.
parent c33d1ca1
Loading
Loading
Loading
Loading
+24 −8
Original line number Diff line number Diff line
@@ -955,14 +955,19 @@ 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.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

@@ -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:
            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)

+40 −1
Original line number Diff line number Diff line
@@ -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`.
+5 −0
Original line number Diff line number Diff line
@@ -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
=====================================

+70 −0
Original line number Diff line number Diff line
@@ -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