Skip to content

Commit

Permalink
Fixed django#15511 -- Allow optional fields on `MultiValueField sub…
Browse files Browse the repository at this point in the history
…classes.

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.
  • Loading branch information
Tai Lee authored and timgraham committed Aug 6, 2013
1 parent c33d1ca commit 1280675
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 9 deletions.
32 changes: 24 additions & 8 deletions django/forms/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -993,15 +998,26 @@ def clean(self, value):
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)

Expand Down
41 changes: 40 additions & 1 deletion docs/ref/forms/fields.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/1.7.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
=====================================

Expand Down
70 changes: 70 additions & 0 deletions tests/forms_tests/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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
Expand Down Expand Up @@ -1792,6 +1793,75 @@ class NameForm(Form):
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
Expand Down

0 comments on commit 1280675

Please sign in to comment.