Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Fixed #20867 -- Added the Form.add_error() method.

Refs #20199 #16986.

Thanks @akaariai, @bmispelon, @mjtamlyn, @timgraham for the reviews.
  • Loading branch information...
commit f563c339ca2eed81706ab17726c79a6f00d7c553 1 parent 7e2d61a
@loic loic authored
View
90 django/core/exceptions.py
@@ -77,64 +77,78 @@ class ValidationError(Exception):
"""An error while validating data."""
def __init__(self, message, code=None, params=None):
"""
- ValidationError can be passed any object that can be printed (usually
- a string), a list of objects or a dictionary.
+ The `message` argument can be a single error, a list of errors, or a
+ dictionary that maps field names to lists of errors. What we define as
+ an "error" can be either a simple string or an instance of
+ ValidationError with its message attribute set, and what we define as
+ list or dictionary can be an actual `list` or `dict` or an instance
+ of ValidationError with its `error_list` or `error_dict` attribute set.
"""
+ if isinstance(message, ValidationError):
+ if hasattr(message, 'error_dict'):
+ message = message.error_dict
+ elif not hasattr(message, 'message'):
+ message = message.error_list
+ else:
+ message, code, params = message.message, message.code, message.params
+
if isinstance(message, dict):
- self.error_dict = message
+ self.error_dict = {}
+ for field, messages in message.items():
+ if not isinstance(messages, ValidationError):
+ messages = ValidationError(messages)
+ self.error_dict[field] = messages.error_list
+
elif isinstance(message, list):
- self.error_list = message
+ self.error_list = []
+ for message in message:
+ # Normalize plain strings to instances of ValidationError.
+ if not isinstance(message, ValidationError):
+ message = ValidationError(message)
+ self.error_list.extend(message.error_list)
+
else:
+ self.message = message
self.code = code
self.params = params
- self.message = message
self.error_list = [self]
@property
def message_dict(self):
- message_dict = {}
- for field, messages in self.error_dict.items():
- message_dict[field] = []
- for message in messages:
- if isinstance(message, ValidationError):
- message_dict[field].extend(message.messages)
- else:
- message_dict[field].append(force_text(message))
- return message_dict
+ return dict(self)
@property
def messages(self):
if hasattr(self, 'error_dict'):
- message_list = reduce(operator.add, self.error_dict.values())
- else:
- message_list = self.error_list
-
- messages = []
- for message in message_list:
- if isinstance(message, ValidationError):
- params = message.params
- message = message.message
- if params:
- message %= params
- message = force_text(message)
- messages.append(message)
- return messages
-
- def __str__(self):
- if hasattr(self, 'error_dict'):
- return repr(self.message_dict)
- return repr(self.messages)
-
- def __repr__(self):
- return 'ValidationError(%s)' % self
+ return reduce(operator.add, dict(self).values())
+ return list(self)
def update_error_dict(self, error_dict):
if hasattr(self, 'error_dict'):
if error_dict:
- for k, v in self.error_dict.items():
- error_dict.setdefault(k, []).extend(v)
+ for field, errors in self.error_dict.items():
+ error_dict.setdefault(field, []).extend(errors)
else:
error_dict = self.error_dict
else:
error_dict[NON_FIELD_ERRORS] = self.error_list
return error_dict
+
+ def __iter__(self):
+ if hasattr(self, 'error_dict'):
+ for field, errors in self.error_dict.items():
+ yield field, list(ValidationError(errors))
+ else:
+ for error in self.error_list:
+ message = error.message
+ if error.params:
+ message %= error.params
+ yield force_text(message)
+
+ def __str__(self):
+ if hasattr(self, 'error_dict'):
+ return repr(dict(self))
+ return repr(list(self))
+
+ def __repr__(self):
+ return 'ValidationError(%s)' % self
View
2  django/db/models/base.py
@@ -987,7 +987,7 @@ def full_clean(self, exclude=None, validate_unique=True):
def clean_fields(self, exclude=None):
"""
- Cleans all fields and raises a ValidationError containing message_dict
+ Cleans all fields and raises a ValidationError containing a dict
of all validation errors if any occur.
"""
if exclude is None:
View
52 django/forms/forms.py
@@ -290,6 +290,51 @@ def _raw_value(self, fieldname):
prefix = self.add_prefix(fieldname)
return field.widget.value_from_datadict(self.data, self.files, prefix)
+ def add_error(self, field, error):
+ """
+ Update the content of `self._errors`.
+
+ The `field` argument is the name of the field to which the errors
+ should be added. If its value is None the errors will be treated as
+ NON_FIELD_ERRORS.
+
+ The `error` argument can be a single error, a list of errors, or a
+ dictionary that maps field names to lists of errors. What we define as
+ an "error" can be either a simple string or an instance of
+ ValidationError with its message attribute set and what we define as
+ list or dictionary can be an actual `list` or `dict` or an instance
+ of ValidationError with its `error_list` or `error_dict` attribute set.
+
+ If `error` is a dictionary, the `field` argument *must* be None and
+ errors will be added to the fields that correspond to the keys of the
+ dictionary.
+ """
+ if not isinstance(error, ValidationError):
+ # Normalize to ValidationError and let its constructor
+ # do the hard work of making sense of the input.
+ error = ValidationError(error)
+
+ if hasattr(error, 'error_dict'):
+ if field is not None:
+ raise TypeError(
+ "The argument `field` must be `None` when the `error` "
+ "argument contains errors for multiple fields."
+ )
+ else:
+ error = dict(error)
+ else:
+ error = {field or NON_FIELD_ERRORS: list(error)}
+
+ for field, error_list in error.items():
+ if field not in self.errors:
+ if field != NON_FIELD_ERRORS and field not in self.fields:
+ raise ValueError(
+ "'%s' has no field named '%s'." % (self.__class__.__name__, field))
+ self._errors[field] = self.error_class()
+ self._errors[field].extend(error_list)
+ if field in self.cleaned_data:
+ del self.cleaned_data[field]
+
def full_clean(self):
"""
Cleans all of self.data and populates self._errors and
@@ -303,6 +348,7 @@ def full_clean(self):
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
return
+
self._clean_fields()
self._clean_form()
self._post_clean()
@@ -324,15 +370,13 @@ def _clean_fields(self):
value = getattr(self, 'clean_%s' % name)()
self.cleaned_data[name] = value
except ValidationError as e:
- self._errors[name] = self.error_class(e.messages)
- if name in self.cleaned_data:
- del self.cleaned_data[name]
+ self.add_error(name, e)
def _clean_form(self):
try:
cleaned_data = self.clean()
except ValidationError as e:
- self._errors[NON_FIELD_ERRORS] = self.error_class(e.messages)
+ self.add_error(None, e)
else:
if cleaned_data is not None:
self.cleaned_data = cleaned_data
View
43 django/forms/models.py
@@ -326,27 +326,6 @@ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
super(BaseModelForm, self).__init__(data, files, auto_id, prefix, object_data,
error_class, label_suffix, empty_permitted)
- def _update_errors(self, errors):
- for field, messages in errors.error_dict.items():
- if field not in self.fields:
- continue
- field = self.fields[field]
- for message in messages:
- if isinstance(message, ValidationError):
- if message.code in field.error_messages:
- message.message = field.error_messages[message.code]
-
- message_dict = errors.message_dict
- for k, v in message_dict.items():
- if k != NON_FIELD_ERRORS:
- self._errors.setdefault(k, self.error_class()).extend(v)
- # Remove the data from the cleaned_data dict since it was invalid
- if k in self.cleaned_data:
- del self.cleaned_data[k]
- if NON_FIELD_ERRORS in message_dict:
- messages = message_dict[NON_FIELD_ERRORS]
- self._errors.setdefault(NON_FIELD_ERRORS, self.error_class()).extend(messages)
-
def _get_validation_exclusions(self):
"""
For backwards-compatibility, several types of fields need to be
@@ -393,6 +372,20 @@ def clean(self):
self._validate_unique = True
return self.cleaned_data
+ def _update_errors(self, errors):
+ # Override any validation error messages defined at the model level
+ # with those defined on the form fields.
+ for field, messages in errors.error_dict.items():
+ if field not in self.fields:
+ continue
+ field = self.fields[field]
+ for message in messages:
+ if (isinstance(message, ValidationError) and
+ message.code in field.error_messages):
+ message.message = field.error_messages[message.code]
+
+ self.add_error(None, errors)
+
def _post_clean(self):
opts = self._meta
# Update the model instance with self.cleaned_data.
@@ -407,13 +400,12 @@ def _post_clean(self):
# object being referred to may not yet fully exist (#12749).
# However, these fields *must* be included in uniqueness checks,
# so this can't be part of _get_validation_exclusions().
- for f_name, field in self.fields.items():
+ for name, field in self.fields.items():
if isinstance(field, InlineForeignKeyField):
- exclude.append(f_name)
+ exclude.append(name)
try:
- self.instance.full_clean(exclude=exclude,
- validate_unique=False)
+ self.instance.full_clean(exclude=exclude, validate_unique=False)
except ValidationError as e:
self._update_errors(e)
@@ -695,6 +687,7 @@ def validate_unique(self):
del form.cleaned_data[field]
# mark the data as seen
seen_data.add(data)
+
if errors:
raise ValidationError(errors)
View
20 docs/ref/forms/api.txt
@@ -117,6 +117,26 @@ The validation routines will only get called once, regardless of how many times
you access :attr:`~Form.errors` or call :meth:`~Form.is_valid`. This means that
if validation has side effects, those side effects will only be triggered once.
+.. method:: Form.add_error(field, error)
+
+.. versionadded:: 1.7
+
+This method allows adding errors to specific fields from within the
+``Form.clean()`` method, or from outside the form altogether; for instance
+from a view. This is a better alternative to fiddling directly with
+``Form._errors`` as described in :ref:`modifying-field-errors`.
+
+The ``field`` argument is the name of the field to which the errors
+should be added. If its value is ``None`` the error will be treated as
+a non-field error as returned by ``Form.non_field_errors()``.
+
+The ``error`` argument can be a simple string, or preferably an instance of
+``ValidationError``. See :ref:`raising-validation-error` for best practices
+when defining form errors.
+
+Note that ``Form.add_error()`` automatically removes the relevant field from
+``cleaned_data``.
+
Behavior of unbound forms
~~~~~~~~~~~~~~~~~~~~~~~~~
View
28 docs/ref/forms/validation.txt
@@ -464,3 +464,31 @@ Secondly, once we have decided that the combined data in the two fields we are
considering aren't valid, we must remember to remove them from the
``cleaned_data``. `cleaned_data`` is present even if the form doesn't
validate, but it contains only field values that did validate.
+
+.. versionchanged:: 1.7
+
+In lieu of manipulating ``_errors`` directly, it's now possible to add errors
+to specific fields with :meth:`django.forms.Form.add_error()`::
+
+ from django import forms
+
+ class ContactForm(forms.Form):
+ # Everything as before.
+ ...
+
+ def clean(self):
+ cleaned_data = super(ContactForm, self).clean()
+ cc_myself = cleaned_data.get("cc_myself")
+ subject = cleaned_data.get("subject")
+
+ if cc_myself and subject and "help" not in subject:
+ msg = u"Must put 'help' in subject when cc'ing yourself."
+ self.add_error('cc_myself', msg)
+ self.add_error('subject', msg)
+
+The second argument of ``add_error()`` can be a simple string, or preferably
+an instance of ``ValidationError``. See :ref:`raising-validation-error` for
+more details.
+
+Unlike the ``_errors`` approach, ``add_error()` automatically removes the field
+from ``cleaned_data``.
View
3  docs/releases/1.7.txt
@@ -350,6 +350,9 @@ Forms
* It's now possible to opt-out from a ``Form`` field declared in a parent class
by shadowing it with a non-``Field`` value.
+* The new :meth:`~django.forms.Form.add_error()` method allows adding errors
+ to specific form fields.
+
Internationalization
^^^^^^^^^^^^^^^^^^^^
View
50 tests/forms_tests/tests/test_forms.py
@@ -657,25 +657,49 @@ def clean_password2(self):
self.assertEqual(f.cleaned_data['password2'], 'foo')
# Another way of doing multiple-field validation is by implementing the
- # Form's clean() method. If you do this, any ValidationError raised by that
- # method will not be associated with a particular field; it will have a
- # special-case association with the field named '__all__'.
- # Note that in Form.clean(), you have access to self.cleaned_data, a dictionary of
- # all the fields/values that have *not* raised a ValidationError. Also note
- # Form.clean() is required to return a dictionary of all clean data.
+ # Form's clean() method. Usually ValidationError raised by that method
+ # will not be associated with a particular field and will have a
+ # special-case association with the field named '__all__'. It's
+ # possible to associate the errors to particular field with the
+ # Form.add_error() method or by passing a dictionary that maps each
+ # field to one or more errors.
+ #
+ # Note that in Form.clean(), you have access to self.cleaned_data, a
+ # dictionary of all the fields/values that have *not* raised a
+ # ValidationError. Also note Form.clean() is required to return a
+ # dictionary of all clean data.
class UserRegistration(Form):
username = CharField(max_length=10)
password1 = CharField(widget=PasswordInput)
password2 = CharField(widget=PasswordInput)
def clean(self):
+ # Test raising a ValidationError as NON_FIELD_ERRORS.
if self.cleaned_data.get('password1') and self.cleaned_data.get('password2') and self.cleaned_data['password1'] != self.cleaned_data['password2']:
raise ValidationError('Please make sure your passwords match.')
+ # Test raising ValidationError that targets multiple fields.
+ errors = {}
+ if self.cleaned_data.get('password1') == 'FORBIDDEN_VALUE':
+ errors['password1'] = 'Forbidden value.'
+ if self.cleaned_data.get('password2') == 'FORBIDDEN_VALUE':
+ errors['password2'] = ['Forbidden value.']
+ if errors:
+ raise ValidationError(errors)
+
+ # Test Form.add_error()
+ if self.cleaned_data.get('password1') == 'FORBIDDEN_VALUE2':
+ self.add_error(None, 'Non-field error 1.')
+ self.add_error('password1', 'Forbidden value 2.')
+ if self.cleaned_data.get('password2') == 'FORBIDDEN_VALUE2':
+ self.add_error('password2', 'Forbidden value 2.')
+ raise ValidationError('Non-field error 2.')
+
return self.cleaned_data
f = UserRegistration(auto_id=False)
self.assertEqual(f.errors, {})
+
f = UserRegistration({}, auto_id=False)
self.assertHTMLEqual(f.as_table(), """<tr><th>Username:</th><td><ul class="errorlist"><li>This field is required.</li></ul><input type="text" name="username" maxlength="10" /></td></tr>
<tr><th>Password1:</th><td><ul class="errorlist"><li>This field is required.</li></ul><input type="password" name="password1" /></td></tr>
@@ -683,6 +707,7 @@ def clean(self):
self.assertEqual(f.errors['username'], ['This field is required.'])
self.assertEqual(f.errors['password1'], ['This field is required.'])
self.assertEqual(f.errors['password2'], ['This field is required.'])
+
f = UserRegistration({'username': 'adrian', 'password1': 'foo', 'password2': 'bar'}, auto_id=False)
self.assertEqual(f.errors['__all__'], ['Please make sure your passwords match.'])
self.assertHTMLEqual(f.as_table(), """<tr><td colspan="2"><ul class="errorlist"><li>Please make sure your passwords match.</li></ul></td></tr>
@@ -693,12 +718,25 @@ def clean(self):
<li>Username: <input type="text" name="username" value="adrian" maxlength="10" /></li>
<li>Password1: <input type="password" name="password1" /></li>
<li>Password2: <input type="password" name="password2" /></li>""")
+
f = UserRegistration({'username': 'adrian', 'password1': 'foo', 'password2': 'foo'}, auto_id=False)
self.assertEqual(f.errors, {})
self.assertEqual(f.cleaned_data['username'], 'adrian')
self.assertEqual(f.cleaned_data['password1'], 'foo')
self.assertEqual(f.cleaned_data['password2'], 'foo')
+ f = UserRegistration({'username': 'adrian', 'password1': 'FORBIDDEN_VALUE', 'password2': 'FORBIDDEN_VALUE'}, auto_id=False)
+ self.assertEqual(f.errors['password1'], ['Forbidden value.'])
+ self.assertEqual(f.errors['password2'], ['Forbidden value.'])
+
+ f = UserRegistration({'username': 'adrian', 'password1': 'FORBIDDEN_VALUE2', 'password2': 'FORBIDDEN_VALUE2'}, auto_id=False)
+ self.assertEqual(f.errors['__all__'], ['Non-field error 1.', 'Non-field error 2.'])
+ self.assertEqual(f.errors['password1'], ['Forbidden value 2.'])
+ self.assertEqual(f.errors['password2'], ['Forbidden value 2.'])
+
+ with six.assertRaisesRegex(self, ValueError, "has no field named"):
+ f.add_error('missing_field', 'Some error.')
+
def test_dynamic_construction(self):
# It's possible to construct a Form dynamically by adding to the self.fields
# dictionary in __init__(). Don't forget to call Form.__init__() within the
Please sign in to comment.
Something went wrong with that request. Please try again.