Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
[1.0.X] Fixed #9493 -- Corrected error handling of formsets that viol…
…ate unique constraints across the component forms. Thanks to Alex Gaynor for the patch.

Merge of r10682 from trunk.

git-svn-id: http://code.djangoproject.com/svn/django/branches/releases/1.0.X@10718 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information
freakboy3742 committed May 8, 2009
1 parent cd4f12d commit 7bcbc99
Show file tree
Hide file tree
Showing 3 changed files with 325 additions and 61 deletions.
136 changes: 117 additions & 19 deletions django/forms/models.py
Expand Up @@ -6,10 +6,10 @@
from django.utils.encoding import smart_unicode, force_unicode
from django.utils.datastructures import SortedDict
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 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 widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
from widgets import media_property
Expand Down Expand Up @@ -225,6 +225,26 @@ def clean(self):
return self.cleaned_data

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

# Gather a list of checks to perform. We only perform unique checks
Expand Down Expand Up @@ -265,24 +285,8 @@ def validate_unique(self):
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:
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):
bad_fields = set()
Expand Down Expand Up @@ -497,6 +501,96 @@ def save_m2m():
self.save_m2m = save_m2m
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):
self.changed_objects = []
self.deleted_objects = []
Expand Down Expand Up @@ -629,6 +723,10 @@ def add_fields(self, form, index):
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):
"""
Finds and returns the ForeignKey from model to parent if there is one.
Expand Down
83 changes: 55 additions & 28 deletions docs/topics/forms/modelforms.txt
Expand Up @@ -45,61 +45,61 @@ the full list of conversions:
Model field Form field
=============================== ========================================
``AutoField`` Not represented in the form

``BooleanField`` ``BooleanField``

``CharField`` ``CharField`` with ``max_length`` set to
the model field's ``max_length``

``CommaSeparatedIntegerField`` ``CharField``

``DateField`` ``DateField``

``DateTimeField`` ``DateTimeField``

``DecimalField`` ``DecimalField``

``EmailField`` ``EmailField``

``FileField`` ``FileField``

``FilePathField`` ``CharField``

``FloatField`` ``FloatField``

``ForeignKey`` ``ModelChoiceField`` (see below)

``ImageField`` ``ImageField``

``IntegerField`` ``IntegerField``

``IPAddressField`` ``IPAddressField``

``ManyToManyField`` ``ModelMultipleChoiceField`` (see
below)

``NullBooleanField`` ``CharField``

``PhoneNumberField`` ``USPhoneNumberField``
(from ``django.contrib.localflavor.us``)

``PositiveIntegerField`` ``IntegerField``

``PositiveSmallIntegerField`` ``IntegerField``

``SlugField`` ``SlugField``

``SmallIntegerField`` ``IntegerField``
``TextField`` ``CharField`` with

``TextField`` ``CharField`` with
``widget=forms.Textarea``

``TimeField`` ``TimeField``

``URLField`` ``URLField`` with ``verify_exists`` set
to the model field's ``verify_exists``
``XMLField`` ``CharField`` with

``XMLField`` ``CharField`` with
``widget=forms.Textarea``
=============================== ========================================

Expand Down Expand Up @@ -455,7 +455,7 @@ queryset that includes all objects in the model (e.g.,

Alternatively, you can create a subclass that sets ``self.queryset`` in
``__init__``::

from django.forms.models import BaseModelFormSet

class BaseAuthorFormSet(BaseModelFormSet):
Expand Down Expand Up @@ -483,6 +483,22 @@ exclude::

.. _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
-----------------------------

Expand Down Expand Up @@ -567,6 +583,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
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
~~~~~~~~~~~~~~~~~~~~~~~

Expand Down

0 comments on commit 7bcbc99

Please sign in to comment.