Fixed #9493 -- Corrected error handling of formsets that violate unique constraints across the component forms. Thanks to Alex Gaynor for the patch.
git-svn-id: http://code.djangoproject.com/svn/django/trunk@10682 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
parent
eb81d5acb3
commit
f259494f82
|
@ -6,10 +6,10 @@ and database field objects.
|
||||||
from django.utils.encoding import smart_unicode, force_unicode
|
from django.utils.encoding import smart_unicode, force_unicode
|
||||||
from django.utils.datastructures import SortedDict
|
from django.utils.datastructures import SortedDict
|
||||||
from django.utils.text import get_text_list, capfirst
|
from django.utils.text import get_text_list, capfirst
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _, ugettext
|
||||||
|
|
||||||
from util import ValidationError, ErrorList
|
from util import ValidationError, ErrorList
|
||||||
from forms import BaseForm, get_declared_fields
|
from forms import BaseForm, get_declared_fields, NON_FIELD_ERRORS
|
||||||
from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES
|
from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES
|
||||||
from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
|
from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
|
||||||
from widgets import media_property
|
from widgets import media_property
|
||||||
|
@ -231,6 +231,26 @@ class BaseModelForm(BaseForm):
|
||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
def validate_unique(self):
|
def validate_unique(self):
|
||||||
|
unique_checks, date_checks = self._get_unique_checks()
|
||||||
|
form_errors = []
|
||||||
|
bad_fields = set()
|
||||||
|
|
||||||
|
field_errors, global_errors = self._perform_unique_checks(unique_checks)
|
||||||
|
bad_fields.union(field_errors)
|
||||||
|
form_errors.extend(global_errors)
|
||||||
|
|
||||||
|
field_errors, global_errors = self._perform_date_checks(date_checks)
|
||||||
|
bad_fields.union(field_errors)
|
||||||
|
form_errors.extend(global_errors)
|
||||||
|
|
||||||
|
for field_name in bad_fields:
|
||||||
|
del self.cleaned_data[field_name]
|
||||||
|
if form_errors:
|
||||||
|
# Raise the unique together errors since they are considered
|
||||||
|
# form-wide.
|
||||||
|
raise ValidationError(form_errors)
|
||||||
|
|
||||||
|
def _get_unique_checks(self):
|
||||||
from django.db.models.fields import FieldDoesNotExist, Field as ModelField
|
from django.db.models.fields import FieldDoesNotExist, Field as ModelField
|
||||||
|
|
||||||
# Gather a list of checks to perform. We only perform unique checks
|
# Gather a list of checks to perform. We only perform unique checks
|
||||||
|
@ -271,24 +291,8 @@ class BaseModelForm(BaseForm):
|
||||||
date_checks.append(('year', name, f.unique_for_year))
|
date_checks.append(('year', name, f.unique_for_year))
|
||||||
if f.unique_for_month and self.cleaned_data.get(f.unique_for_month) is not None:
|
if f.unique_for_month and self.cleaned_data.get(f.unique_for_month) is not None:
|
||||||
date_checks.append(('month', name, f.unique_for_month))
|
date_checks.append(('month', name, f.unique_for_month))
|
||||||
|
return unique_checks, date_checks
|
||||||
|
|
||||||
form_errors = []
|
|
||||||
bad_fields = set()
|
|
||||||
|
|
||||||
field_errors, global_errors = self._perform_unique_checks(unique_checks)
|
|
||||||
bad_fields.union(field_errors)
|
|
||||||
form_errors.extend(global_errors)
|
|
||||||
|
|
||||||
field_errors, global_errors = self._perform_date_checks(date_checks)
|
|
||||||
bad_fields.union(field_errors)
|
|
||||||
form_errors.extend(global_errors)
|
|
||||||
|
|
||||||
for field_name in bad_fields:
|
|
||||||
del self.cleaned_data[field_name]
|
|
||||||
if form_errors:
|
|
||||||
# Raise the unique together errors since they are considered
|
|
||||||
# form-wide.
|
|
||||||
raise ValidationError(form_errors)
|
|
||||||
|
|
||||||
def _perform_unique_checks(self, unique_checks):
|
def _perform_unique_checks(self, unique_checks):
|
||||||
bad_fields = set()
|
bad_fields = set()
|
||||||
|
@ -504,6 +508,96 @@ class BaseModelFormSet(BaseFormSet):
|
||||||
self.save_m2m = save_m2m
|
self.save_m2m = save_m2m
|
||||||
return self.save_existing_objects(commit) + self.save_new_objects(commit)
|
return self.save_existing_objects(commit) + self.save_new_objects(commit)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
self.validate_unique()
|
||||||
|
|
||||||
|
def validate_unique(self):
|
||||||
|
# Iterate over the forms so that we can find one with potentially valid
|
||||||
|
# data from which to extract the error checks
|
||||||
|
for form in self.forms:
|
||||||
|
if hasattr(form, 'cleaned_data'):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
unique_checks, date_checks = form._get_unique_checks()
|
||||||
|
errors = []
|
||||||
|
# Do each of the unique checks (unique and unique_together)
|
||||||
|
for unique_check in unique_checks:
|
||||||
|
seen_data = set()
|
||||||
|
for form in self.forms:
|
||||||
|
# if the form doesn't have cleaned_data then we ignore it,
|
||||||
|
# it's already invalid
|
||||||
|
if not hasattr(form, "cleaned_data"):
|
||||||
|
continue
|
||||||
|
# get each of the fields for which we have data on this form
|
||||||
|
if [f for f in unique_check if f in form.cleaned_data and form.cleaned_data[f] is not None]:
|
||||||
|
# get the data itself
|
||||||
|
row_data = tuple([form.cleaned_data[field] for field in unique_check])
|
||||||
|
# if we've aready seen it then we have a uniqueness failure
|
||||||
|
if row_data in seen_data:
|
||||||
|
# poke error messages into the right places and mark
|
||||||
|
# the form as invalid
|
||||||
|
errors.append(self.get_unique_error_message(unique_check))
|
||||||
|
form._errors[NON_FIELD_ERRORS] = self.get_form_error()
|
||||||
|
del form.cleaned_data
|
||||||
|
break
|
||||||
|
# mark the data as seen
|
||||||
|
seen_data.add(row_data)
|
||||||
|
# iterate over each of the date checks now
|
||||||
|
for date_check in date_checks:
|
||||||
|
seen_data = set()
|
||||||
|
lookup, field, unique_for = date_check
|
||||||
|
for form in self.forms:
|
||||||
|
# if the form doesn't have cleaned_data then we ignore it,
|
||||||
|
# it's already invalid
|
||||||
|
if not hasattr(self, 'cleaned_data'):
|
||||||
|
continue
|
||||||
|
# see if we have data for both fields
|
||||||
|
if (form.cleaned_data and form.cleaned_data[field] is not None
|
||||||
|
and form.cleaned_data[unique_for] is not None):
|
||||||
|
# if it's a date lookup we need to get the data for all the fields
|
||||||
|
if lookup == 'date':
|
||||||
|
date = form.cleaned_data[unique_for]
|
||||||
|
date_data = (date.year, date.month, date.day)
|
||||||
|
# otherwise it's just the attribute on the date/datetime
|
||||||
|
# object
|
||||||
|
else:
|
||||||
|
date_data = (getattr(form.cleaned_data[unique_for], lookup),)
|
||||||
|
data = (form.cleaned_data[field],) + date_data
|
||||||
|
# if we've aready seen it then we have a uniqueness failure
|
||||||
|
if data in seen_data:
|
||||||
|
# poke error messages into the right places and mark
|
||||||
|
# the form as invalid
|
||||||
|
errors.append(self.get_date_error_message(date_check))
|
||||||
|
form._errors[NON_FIELD_ERRORS] = self.get_form_error()
|
||||||
|
del form.cleaned_data
|
||||||
|
break
|
||||||
|
seen_data.add(data)
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
def get_unique_error_message(self, unique_check):
|
||||||
|
if len(unique_check) == 1:
|
||||||
|
return ugettext("Please correct the duplicate data for %(field)s.") % {
|
||||||
|
"field": unique_check[0],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return ugettext("Please correct the duplicate data for %(field)s, "
|
||||||
|
"which must be unique.") % {
|
||||||
|
"field": get_text_list(unique_check, _("and")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_date_error_message(self, date_check):
|
||||||
|
return ugettext("Please correct the duplicate data for %(field_name)s "
|
||||||
|
"which must be unique for the %(lookup)s in %(date_field)s.") % {
|
||||||
|
'field_name': date_check[1],
|
||||||
|
'date_field': date_check[2],
|
||||||
|
'lookup': unicode(date_check[0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_form_error(self):
|
||||||
|
return ugettext("Please correct the duplicate values below.")
|
||||||
|
|
||||||
def save_existing_objects(self, commit=True):
|
def save_existing_objects(self, commit=True):
|
||||||
self.changed_objects = []
|
self.changed_objects = []
|
||||||
self.deleted_objects = []
|
self.deleted_objects = []
|
||||||
|
@ -657,6 +751,10 @@ class BaseInlineFormSet(BaseModelFormSet):
|
||||||
label=getattr(form.fields.get(self.fk.name), 'label', capfirst(self.fk.verbose_name))
|
label=getattr(form.fields.get(self.fk.name), 'label', capfirst(self.fk.verbose_name))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_unique_error_message(self, unique_check):
|
||||||
|
unique_check = [field for field in unique_check if field != self.fk.name]
|
||||||
|
return super(BaseInlineFormSet, self).get_unique_error_message(unique_check)
|
||||||
|
|
||||||
def _get_foreign_key(parent_model, model, fk_name=None):
|
def _get_foreign_key(parent_model, model, fk_name=None):
|
||||||
"""
|
"""
|
||||||
Finds and returns the ForeignKey from model to parent if there is one.
|
Finds and returns the ForeignKey from model to parent if there is one.
|
||||||
|
|
|
@ -515,6 +515,22 @@ exclude::
|
||||||
|
|
||||||
.. _saving-objects-in-the-formset:
|
.. _saving-objects-in-the-formset:
|
||||||
|
|
||||||
|
Overriding clean() method
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
You can override the ``clean()`` method to provide custom validation to
|
||||||
|
the whole formset at once. By default, the ``clean()`` method will validate
|
||||||
|
that none of the data in the formsets violate the unique constraints on your
|
||||||
|
model (both field ``unique`` and model ``unique_together``). To maintain this
|
||||||
|
default behavior be sure you call the parent's ``clean()`` method::
|
||||||
|
|
||||||
|
class MyModelFormSet(BaseModelFormSet):
|
||||||
|
def clean(self):
|
||||||
|
super(MyModelFormSet, self).clean()
|
||||||
|
# example custom validation across forms in the formset:
|
||||||
|
for form in self.forms:
|
||||||
|
# your custom formset validation
|
||||||
|
|
||||||
Saving objects in the formset
|
Saving objects in the formset
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
@ -599,6 +615,17 @@ than that of a "normal" formset. The only difference is that we call
|
||||||
``formset.save()`` to save the data into the database. (This was described
|
``formset.save()`` to save the data into the database. (This was described
|
||||||
above, in :ref:`saving-objects-in-the-formset`.)
|
above, in :ref:`saving-objects-in-the-formset`.)
|
||||||
|
|
||||||
|
|
||||||
|
Overiding ``clean()`` on a ``model_formset``
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
Just like with ``ModelForms``, by default the ``clean()`` method of a
|
||||||
|
``model_formset`` will validate that none of the items in the formset validate
|
||||||
|
the unique constraints on your model(either unique or unique_together). If you
|
||||||
|
want to overide the ``clean()`` method on a ``model_formset`` and maintain this
|
||||||
|
validation, you must call the parent classes ``clean`` method.
|
||||||
|
|
||||||
|
|
||||||
Using a custom queryset
|
Using a custom queryset
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,12 @@ class Book(models.Model):
|
||||||
author = models.ForeignKey(Author)
|
author = models.ForeignKey(Author)
|
||||||
title = models.CharField(max_length=100)
|
title = models.CharField(max_length=100)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (
|
||||||
|
('author', 'title'),
|
||||||
|
)
|
||||||
|
ordering = ['id']
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
@ -166,6 +172,15 @@ class Poem(models.Model):
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
class Post(models.Model):
|
||||||
|
title = models.CharField(max_length=50, unique_for_date='posted', blank=True)
|
||||||
|
slug = models.CharField(max_length=50, unique_for_year='posted', blank=True)
|
||||||
|
subtitle = models.CharField(max_length=50, unique_for_month='posted', blank=True)
|
||||||
|
posted = models.DateField()
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
__test__ = {'API_TESTS': """
|
__test__ = {'API_TESTS': """
|
||||||
|
|
||||||
>>> from datetime import date
|
>>> from datetime import date
|
||||||
|
@ -943,4 +958,129 @@ True
|
||||||
>>> FormSet = modelformset_factory(ClassyMexicanRestaurant, fields=["tacos_are_yummy"])
|
>>> FormSet = modelformset_factory(ClassyMexicanRestaurant, fields=["tacos_are_yummy"])
|
||||||
>>> sorted(FormSet().forms[0].fields.keys())
|
>>> sorted(FormSet().forms[0].fields.keys())
|
||||||
['restaurant', 'tacos_are_yummy']
|
['restaurant', 'tacos_are_yummy']
|
||||||
|
|
||||||
|
# Prevent duplicates from within the same formset
|
||||||
|
>>> FormSet = modelformset_factory(Product, extra=2)
|
||||||
|
>>> data = {
|
||||||
|
... 'form-TOTAL_FORMS': 2,
|
||||||
|
... 'form-INITIAL_FORMS': 0,
|
||||||
|
... 'form-0-slug': 'red_car',
|
||||||
|
... 'form-1-slug': 'red_car',
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
False
|
||||||
|
>>> formset._non_form_errors
|
||||||
|
[u'Please correct the duplicate data for slug.']
|
||||||
|
|
||||||
|
>>> FormSet = modelformset_factory(Price, extra=2)
|
||||||
|
>>> data = {
|
||||||
|
... 'form-TOTAL_FORMS': 2,
|
||||||
|
... 'form-INITIAL_FORMS': 0,
|
||||||
|
... 'form-0-price': '25',
|
||||||
|
... 'form-0-quantity': '7',
|
||||||
|
... 'form-1-price': '25',
|
||||||
|
... 'form-1-quantity': '7',
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
False
|
||||||
|
>>> formset._non_form_errors
|
||||||
|
[u'Please correct the duplicate data for price and quantity, which must be unique.']
|
||||||
|
|
||||||
|
# only the price field is specified, this should skip any unique checks since the unique_together is not fulfilled.
|
||||||
|
# this will fail with a KeyError if broken.
|
||||||
|
>>> FormSet = modelformset_factory(Price, fields=("price",), extra=2)
|
||||||
|
>>> data = {
|
||||||
|
... 'form-TOTAL_FORMS': '2',
|
||||||
|
... 'form-INITIAL_FORMS': '0',
|
||||||
|
... 'form-0-price': '24',
|
||||||
|
... 'form-1-price': '24',
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> FormSet = inlineformset_factory(Author, Book, extra=0)
|
||||||
|
>>> author = Author.objects.order_by('id')[0]
|
||||||
|
>>> book_ids = author.book_set.values_list('id', flat=True)
|
||||||
|
>>> data = {
|
||||||
|
... 'book_set-TOTAL_FORMS': '2',
|
||||||
|
... 'book_set-INITIAL_FORMS': '2',
|
||||||
|
...
|
||||||
|
... 'book_set-0-title': 'The 2008 Election',
|
||||||
|
... 'book_set-0-author': str(author.id),
|
||||||
|
... 'book_set-0-id': str(book_ids[0]),
|
||||||
|
...
|
||||||
|
... 'book_set-1-title': 'The 2008 Election',
|
||||||
|
... 'book_set-1-author': str(author.id),
|
||||||
|
... 'book_set-1-id': str(book_ids[1]),
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data=data, instance=author)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
False
|
||||||
|
>>> formset._non_form_errors
|
||||||
|
[u'Please correct the duplicate data for title.']
|
||||||
|
>>> formset.errors
|
||||||
|
[{}, {'__all__': u'Please correct the duplicate values below.'}]
|
||||||
|
|
||||||
|
>>> FormSet = modelformset_factory(Post, extra=2)
|
||||||
|
>>> data = {
|
||||||
|
... 'form-TOTAL_FORMS': '2',
|
||||||
|
... 'form-INITIAL_FORMS': '0',
|
||||||
|
...
|
||||||
|
... 'form-0-title': 'blah',
|
||||||
|
... 'form-0-slug': 'Morning',
|
||||||
|
... 'form-0-subtitle': 'foo',
|
||||||
|
... 'form-0-posted': '2009-01-01',
|
||||||
|
... 'form-1-title': 'blah',
|
||||||
|
... 'form-1-slug': 'Morning in Prague',
|
||||||
|
... 'form-1-subtitle': 'rawr',
|
||||||
|
... 'form-1-posted': '2009-01-01'
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
False
|
||||||
|
>>> formset._non_form_errors
|
||||||
|
[u'Please correct the duplicate data for title which must be unique for the date in posted.']
|
||||||
|
>>> formset.errors
|
||||||
|
[{}, {'__all__': u'Please correct the duplicate values below.'}]
|
||||||
|
|
||||||
|
>>> data = {
|
||||||
|
... 'form-TOTAL_FORMS': '2',
|
||||||
|
... 'form-INITIAL_FORMS': '0',
|
||||||
|
...
|
||||||
|
... 'form-0-title': 'foo',
|
||||||
|
... 'form-0-slug': 'Morning in Prague',
|
||||||
|
... 'form-0-subtitle': 'foo',
|
||||||
|
... 'form-0-posted': '2009-01-01',
|
||||||
|
... 'form-1-title': 'blah',
|
||||||
|
... 'form-1-slug': 'Morning in Prague',
|
||||||
|
... 'form-1-subtitle': 'rawr',
|
||||||
|
... 'form-1-posted': '2009-08-02'
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
False
|
||||||
|
>>> formset._non_form_errors
|
||||||
|
[u'Please correct the duplicate data for slug which must be unique for the year in posted.']
|
||||||
|
|
||||||
|
>>> data = {
|
||||||
|
... 'form-TOTAL_FORMS': '2',
|
||||||
|
... 'form-INITIAL_FORMS': '0',
|
||||||
|
...
|
||||||
|
... 'form-0-title': 'foo',
|
||||||
|
... 'form-0-slug': 'Morning in Prague',
|
||||||
|
... 'form-0-subtitle': 'rawr',
|
||||||
|
... 'form-0-posted': '2008-08-01',
|
||||||
|
... 'form-1-title': 'blah',
|
||||||
|
... 'form-1-slug': 'Prague',
|
||||||
|
... 'form-1-subtitle': 'rawr',
|
||||||
|
... 'form-1-posted': '2009-08-02'
|
||||||
|
... }
|
||||||
|
>>> formset = FormSet(data)
|
||||||
|
>>> formset.is_valid()
|
||||||
|
False
|
||||||
|
>>> formset._non_form_errors
|
||||||
|
[u'Please correct the duplicate data for subtitle which must be unique for the month in posted.']
|
||||||
"""}
|
"""}
|
||||||
|
|
Loading…
Reference in New Issue