From 8847a0c601e4261823b1726b2db73eec2ac17940 Mon Sep 17 00:00:00 2001 From: Loic Bistuer Date: Tue, 4 Feb 2014 01:31:27 +0700 Subject: [PATCH] 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. --- django/db/models/base.py | 56 ++++++++++++++++++----------- django/db/models/fields/__init__.py | 2 ++ django/forms/forms.py | 4 +-- django/forms/models.py | 16 ++++++--- docs/ref/exceptions.txt | 9 +++++ docs/ref/models/fields.txt | 8 +++-- docs/ref/models/instances.txt | 4 +-- docs/ref/models/options.txt | 5 +++ docs/releases/1.7.txt | 8 +++++ docs/topics/forms/modelforms.txt | 19 +++++++++- tests/model_forms/tests.py | 45 ++++++++++++++++++++++- 11 files changed, 142 insertions(+), 34 deletions(-) diff --git a/django/db/models/base.py b/django/db/models/base.py index e689f7be8a..4b8aededa9 100644 --- a/django/db/models/base.py +++ b/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): """ diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index dca7fb7a6c..4454429997 100644 --- a/django/db/models/fields/__init__.py +++ b/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() diff --git a/django/forms/forms.py b/django/forms/forms.py index bcc0d6dfa4..e34e089878 100644 --- a/django/forms/forms.py +++ b/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'""" diff --git a/django/forms/models.py b/django/forms/models.py index 7c4c92c140..37a1b93bf5 100644 --- a/django/forms/models.py +++ b/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) diff --git a/docs/ref/exceptions.txt b/docs/ref/exceptions.txt index 956ee5a188..c84dd87ed3 100644 --- a/docs/ref/exceptions.txt +++ b/docs/ref/exceptions.txt @@ -124,6 +124,15 @@ ValidationError :ref:`Model Field Validation ` and the :doc:`Validator Reference `. +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 diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 22fadd26c3..c6a5311760 100644 --- a/docs/ref/models/fields.txt +++ b/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`` ------------- diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index 6bc6917a9e..2177bc6a59 100644 --- a/docs/ref/models/instances.txt +++ b/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: diff --git a/docs/ref/models/options.txt b/docs/ref/models/options.txt index ba07dfaeb9..ae2d7def0f 100644 --- a/docs/ref/models/options.txt +++ b/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 ` model. + .. versionchanged:: 1.7 + + The ``ValidationError`` raised during model validation when the + constraint is violated has the ``unique_together`` error code. + ``index_together`` ------------------ diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index a51c3dc981..f9d3a7e795 100644 --- a/docs/releases/1.7.txt +++ b/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 + ` for more details. + Internationalization ^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt index e98fcb0344..1cac2d6e7e 100644 --- a/docs/topics/forms/modelforms.txt +++ b/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 ` 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 ` level always take precedence over the error messages defined at the :attr:`model field ` level. + Error messages defined on :attr:`model fields ` are only used when the ``ValidationError`` is raised during the :ref:`model validation ` 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 --------------------- diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index c5e16a2c14..c64bad5241 100644 --- a/tests/model_forms/tests.py +++ b/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): """