Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
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.
  • Loading branch information...
commit 12806758347dfd63a3cd1bfc0d925c09fdbd9cff 1 parent c33d1ca
Tai Lee authored timgraham committed
32 django/forms/fields.py
View
@@ -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 @@ 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)
41 docs/ref/forms/fields.txt
View
@@ -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 docs/releases/1.7.txt
View
@@ -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 tests/forms_tests/tests/test_forms.py
View
@@ -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
@@ -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
Please sign in to comment.
Something went wrong with that request. Please try again.