Browse Source

Fixed #16192 -- Made unique error messages in ModelForm customizable.

Overriding the error messages now works for both unique fields, unique_together
and unique_for_date.

This patch changed the overriding logic to allow customizing NON_FIELD_ERRORS
since previously only fields' errors were customizable.

Refs #20199.

Thanks leahculver for the suggestion.
Loic Bistuer 11 years ago
parent
commit
8847a0c601

+ 36 - 20
django/db/models/base.py

@@ -941,36 +941,52 @@ class Model(six.with_metaclass(ModelBase)):
                 )
         return errors
 
-    def date_error_message(self, lookup_type, field, unique_for):
+    def date_error_message(self, lookup_type, field_name, unique_for):
         opts = self._meta
-        return _("%(field_name)s must be unique for %(date_field)s %(lookup)s.") % {
-            'field_name': six.text_type(capfirst(opts.get_field(field).verbose_name)),
-            'date_field': six.text_type(capfirst(opts.get_field(unique_for).verbose_name)),
-            'lookup': lookup_type,
-        }
+        field = opts.get_field(field_name)
+        return ValidationError(
+            message=field.error_messages['unique_for_date'],
+            code='unique_for_date',
+            params={
+                'model': self,
+                'model_name': six.text_type(capfirst(opts.verbose_name)),
+                'lookup_type': lookup_type,
+                'field': field_name,
+                'field_label': six.text_type(capfirst(field.verbose_name)),
+                'date_field': unique_for,
+                'date_field_label': six.text_type(capfirst(opts.get_field(unique_for).verbose_name)),
+            }
+        )
 
     def unique_error_message(self, model_class, unique_check):
         opts = model_class._meta
-        model_name = capfirst(opts.verbose_name)
+
+        params = {
+            'model': self,
+            'model_class': model_class,
+            'model_name': six.text_type(capfirst(opts.verbose_name)),
+            'unique_check': unique_check,
+        }
 
         # A unique field
         if len(unique_check) == 1:
-            field_name = unique_check[0]
-            field = opts.get_field(field_name)
-            field_label = capfirst(field.verbose_name)
-            # Insert the error into the error dict, very sneaky
-            return field.error_messages['unique'] % {
-                'model_name': six.text_type(model_name),
-                'field_label': six.text_type(field_label)
-            }
+            field = opts.get_field(unique_check[0])
+            params['field_label'] = six.text_type(capfirst(field.verbose_name))
+            return ValidationError(
+                message=field.error_messages['unique'],
+                code='unique',
+                params=params,
+            )
+
         # unique_together
         else:
             field_labels = [capfirst(opts.get_field(f).verbose_name) for f in unique_check]
-            field_labels = get_text_list(field_labels, _('and'))
-            return _("%(model_name)s with this %(field_label)s already exists.") % {
-                'model_name': six.text_type(model_name),
-                'field_label': six.text_type(field_labels)
-            }
+            params['field_labels'] = six.text_type(get_text_list(field_labels, _('and')))
+            return ValidationError(
+                message=_("%(model_name)s with this %(field_labels)s already exists."),
+                code='unique_together',
+                params=params,
+            )
 
     def full_clean(self, exclude=None, validate_unique=True):
         """

+ 2 - 0
django/db/models/fields/__init__.py

@@ -105,6 +105,8 @@ class Field(RegisterLookupMixin):
         'blank': _('This field cannot be blank.'),
         'unique': _('%(model_name)s with this %(field_label)s '
                     'already exists.'),
+        'unique_for_date': _("%(field_label)s must be unique for "
+                             "%(date_field_label)s %(lookup_type)s."),
     }
     class_lookups = default_lookups.copy()
 

+ 1 - 3
django/forms/forms.py

@@ -8,7 +8,7 @@ from collections import OrderedDict
 import copy
 import warnings
 
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
 from django.forms.fields import Field, FileField
 from django.forms.utils import flatatt, ErrorDict, ErrorList
 from django.forms.widgets import Media, MediaDefiningClass, TextInput, Textarea
@@ -21,8 +21,6 @@ from django.utils import six
 
 __all__ = ('BaseForm', 'Form')
 
-NON_FIELD_ERRORS = '__all__'
-
 
 def pretty_name(name):
     """Converts 'first_name' to 'First name'"""

+ 11 - 5
django/forms/models.py

@@ -373,15 +373,21 @@ class BaseModelForm(BaseForm):
 
     def _update_errors(self, errors):
         # Override any validation error messages defined at the model level
-        # with those defined on the form fields.
+        # with those defined at the form level.
+        opts = self._meta
         for field, messages in errors.error_dict.items():
-            if field not in self.fields:
+            if (field == NON_FIELD_ERRORS and opts.error_messages and
+                    NON_FIELD_ERRORS in opts.error_messages):
+                error_messages = opts.error_messages[NON_FIELD_ERRORS]
+            elif field in self.fields:
+                error_messages = self.fields[field].error_messages
+            else:
                 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]
+                        message.code in error_messages):
+                    message.message = error_messages[message.code]
 
         self.add_error(None, errors)
 

+ 9 - 0
docs/ref/exceptions.txt

@@ -124,6 +124,15 @@ ValidationError
     :ref:`Model Field Validation <validating-objects>` and the
     :doc:`Validator Reference </ref/validators>`.
 
+NON_FIELD_ERRORS
+~~~~~~~~~~~~~~~~
+.. data:: NON_FIELD_ERRORS
+
+``ValidationError``\s that don't belong to a particular field in a form
+or model are classified as ``NON_FIELD_ERRORS``. This constant is used
+as a key in dictonaries that otherwise map fields to their respective
+list of errors.
+
 .. currentmodule:: django.core.urlresolvers
 
 URL Resolver exceptions

+ 6 - 2
docs/ref/models/fields.txt

@@ -233,8 +233,12 @@ field will raise. Pass in a dictionary with keys matching the error messages you
 want to override.
 
 Error message keys include ``null``, ``blank``, ``invalid``, ``invalid_choice``,
-and ``unique``. Additional error message keys are specified for each field in
-the `Field types`_ section below.
+``unique``, and ``unique_for_date``. Additional error message keys are
+specified for each field in the `Field types`_ section below.
+
+.. versionadded:: 1.7
+
+The ``unique_for_date`` error message key was added.
 
 ``help_text``
 -------------

+ 2 - 2
docs/ref/models/instances.txt

@@ -151,8 +151,8 @@ access to more than a single field::
 
 Any :exc:`~django.core.exceptions.ValidationError` exceptions raised by
 ``Model.clean()`` will be stored in a special key error dictionary key,
-``NON_FIELD_ERRORS``, that is used for errors that are tied to the entire model
-instead of to a specific field::
+:data:`~django.core.exceptions.NON_FIELD_ERRORS`, that is used for errors
+that are tied to the entire model instead of to a specific field::
 
     from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
     try:

+ 5 - 0
docs/ref/models/options.txt

@@ -321,6 +321,11 @@ Django quotes column and table names behind the scenes.
     :class:`~django.db.models.ManyToManyField`, try using a signal or
     an explicit :attr:`through <ManyToManyField.through>` model.
 
+    .. versionchanged:: 1.7
+
+    The ``ValidationError`` raised during model validation when the
+    constraint is violated has the ``unique_together`` error code.
+
 ``index_together``
 ------------------
 

+ 8 - 0
docs/releases/1.7.txt

@@ -484,6 +484,14 @@ Forms
   that maps fields to their original errors, complete with all metadata
   (error code and params), the latter returns the errors serialized as json.
 
+* It's now possible to customize the error messages for ``ModelForm``’s
+  ``unique``, ``unique_for_date``, and ``unique_together`` constraints.
+  In order to support ``unique_together`` or any other ``NON_FIELD_ERROR``,
+  ``ModelForm`` now looks for the ``NON_FIELD_ERROR`` key in the
+  ``error_messages`` dictionary of the ``ModelForm``’s inner ``Meta`` class.
+  See :ref:`considerations regarding model's error_messages
+  <considerations-regarding-model-errormessages>` for more details.
+
 Internationalization
 ^^^^^^^^^^^^^^^^^^^^
 

+ 18 - 1
docs/topics/forms/modelforms.txt

@@ -259,7 +259,9 @@ The model's ``clean()`` method will be called before any uniqueness checks are
 made. See :ref:`Validating objects <validating-objects>` for more information
 on the model's ``clean()`` hook.
 
-Considerations regarding fields' ``error_messages``
+.. _considerations-regarding-model-errormessages:
+
+Considerations regarding model's ``error_messages``
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 Error messages defined at the
@@ -267,12 +269,27 @@ Error messages defined at the
 :ref:`form Meta <modelforms-overriding-default-fields>` level always take
 precedence over the error messages defined at the
 :attr:`model field <django.db.models.Field.error_messages>` level.
+
 Error messages  defined on :attr:`model fields
 <django.db.models.Field.error_messages>` are only used when the
 ``ValidationError`` is raised during the :ref:`model validation
 <validating-objects>` step and no corresponding error messages are defined at
 the form level.
 
+.. versionadded:: 1.7
+
+You can override the error messages from ``NON_FIELD_ERRORS`` raised by model
+validation by adding the :data:`~django.core.exceptions.NON_FIELD_ERRORS` key
+to the ``error_messages`` dictionary of the ``ModelForm``’s inner ``Meta`` class::
+
+    class ArticleForm(ModelForm):
+        class Meta:
+            error_messages = {
+                NON_FIELD_ERRORS: {
+                    'unique_together': "%(model_name)s's %(field_labels)s are not unique.",
+                }
+            }
+
 The ``save()`` method
 ---------------------
 

+ 44 - 1
tests/model_forms/tests.py

@@ -7,7 +7,7 @@ from unittest import skipUnless
 import warnings
 
 from django import forms
-from django.core.exceptions import FieldError
+from django.core.exceptions import FieldError, NON_FIELD_ERRORS
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.validators import ValidationError
 from django.db import connection
@@ -792,6 +792,49 @@ class UniqueTest(TestCase):
             "slug": "Django 1.0"}, instance=p)
         self.assertTrue(form.is_valid())
 
+    def test_override_unique_message(self):
+        class CustomProductForm(ProductForm):
+            class Meta(ProductForm.Meta):
+                error_messages = {
+                    'slug': {
+                        'unique': "%(model_name)s's %(field_label)s not unique.",
+                    }
+                }
+
+        Product.objects.create(slug='teddy-bear-blue')
+        form = CustomProductForm({'slug': 'teddy-bear-blue'})
+        self.assertEqual(len(form.errors), 1)
+        self.assertEqual(form.errors['slug'], ["Product's Slug not unique."])
+
+    def test_override_unique_together_message(self):
+        class CustomPriceForm(PriceForm):
+            class Meta(PriceForm.Meta):
+                error_messages = {
+                    NON_FIELD_ERRORS: {
+                        'unique_together': "%(model_name)s's %(field_labels)s not unique.",
+                    }
+                }
+
+        Price.objects.create(price=6.00, quantity=1)
+        form = CustomPriceForm({'price': '6.00', 'quantity': '1'})
+        self.assertEqual(len(form.errors), 1)
+        self.assertEqual(form.errors[NON_FIELD_ERRORS], ["Price's Price and Quantity not unique."])
+
+    def test_override_unique_for_date_message(self):
+        class CustomPostForm(PostForm):
+            class Meta(PostForm.Meta):
+                error_messages = {
+                    'title': {
+                        'unique_for_date': "%(model_name)s's %(field_label)s not unique for %(date_field_label)s date.",
+                    }
+                }
+
+        Post.objects.create(title="Django 1.0 is released",
+            slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3))
+        form = CustomPostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'})
+        self.assertEqual(len(form.errors), 1)
+        self.assertEqual(form.errors['title'], ["Post's Title not unique for Posted date."])
+
 
 class ModelToDictTests(TestCase):
     """