Author: russellm
Date: 2009-05-08 11:04:59 -0500 (Fri, 08 May 2009)
New Revision: 10718

Modified:
   django/branches/releases/1.0.X/django/forms/models.py
   django/branches/releases/1.0.X/docs/topics/forms/modelforms.txt
   django/branches/releases/1.0.X/tests/modeltests/model_formsets/models.py
Log:
[1.0.X] Fixed #9493 -- Corrected error handling of formsets that violate unique 
constraints across the component forms. Thanks to Alex Gaynor for the patch.

Merge of r10682 from trunk.

Modified: django/branches/releases/1.0.X/django/forms/models.py
===================================================================
--- django/branches/releases/1.0.X/django/forms/models.py       2009-05-08 
15:08:09 UTC (rev 10717)
+++ django/branches/releases/1.0.X/django/forms/models.py       2009-05-08 
16:04:59 UTC (rev 10718)
@@ -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
@@ -225,6 +225,26 @@
         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
@@ -265,25 +285,9 @@
                 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()
         form_errors = []
@@ -497,6 +501,96 @@
             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 = []
@@ -629,6 +723,10 @@
                 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.

Modified: django/branches/releases/1.0.X/docs/topics/forms/modelforms.txt
===================================================================
--- django/branches/releases/1.0.X/docs/topics/forms/modelforms.txt     
2009-05-08 15:08:09 UTC (rev 10717)
+++ django/branches/releases/1.0.X/docs/topics/forms/modelforms.txt     
2009-05-08 16:04:59 UTC (rev 10718)
@@ -45,61 +45,61 @@
     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``
     ===============================  ========================================
 
@@ -455,7 +455,7 @@
 
 Alternatively, you can create a subclass that sets ``self.queryset`` in
 ``__init__``::
-    
+
     from django.forms.models import BaseModelFormSet
 
     class BaseAuthorFormSet(BaseModelFormSet):
@@ -483,6 +483,22 @@
 
 .. _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
 -----------------------------
 
@@ -567,6 +583,17 @@
 ``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
 ~~~~~~~~~~~~~~~~~~~~~~~
 

Modified: 
django/branches/releases/1.0.X/tests/modeltests/model_formsets/models.py
===================================================================
--- django/branches/releases/1.0.X/tests/modeltests/model_formsets/models.py    
2009-05-08 15:08:09 UTC (rev 10717)
+++ django/branches/releases/1.0.X/tests/modeltests/model_formsets/models.py    
2009-05-08 16:04:59 UTC (rev 10718)
@@ -25,9 +25,15 @@
     author = models.ForeignKey(Author)
     title = models.CharField(max_length=100)
 
+    class Meta:
+        unique_together = (
+            ('author', 'title'),
+        )
+        ordering = ['id']
+
     def __unicode__(self):
         return self.title
-    
+
 class BookWithCustomPK(models.Model):
     my_pk = models.DecimalField(max_digits=5, decimal_places=0, 
primary_key=True)
     author = models.ForeignKey(Author)
@@ -35,13 +41,13 @@
 
     def __unicode__(self):
         return u'%s: %s' % (self.my_pk, self.title)
-    
+
 class AlternateBook(Book):
     notes = models.CharField(max_length=100)
-    
+
     def __unicode__(self):
         return u'%s - %s' % (self.title, self.notes)
-    
+
 class AuthorMeeting(models.Model):
     name = models.CharField(max_length=100)
     authors = models.ManyToManyField(Author)
@@ -60,7 +66,7 @@
 class Place(models.Model):
     name = models.CharField(max_length=50)
     city = models.CharField(max_length=50)
-    
+
     def __unicode__(self):
         return self.name
 
@@ -68,7 +74,7 @@
     auto_id = models.AutoField(primary_key=True)
     name = models.CharField(max_length=100)
     place = models.ForeignKey(Place)
-    
+
     def __unicode__(self):
         return "%s at %s" % (self.name, self.place)
 
@@ -81,13 +87,13 @@
 class OwnerProfile(models.Model):
     owner = models.OneToOneField(Owner, primary_key=True)
     age = models.PositiveIntegerField()
-    
+
     def __unicode__(self):
         return "%s is %d" % (self.owner.name, self.age)
 
 class Restaurant(Place):
     serves_pizza = models.BooleanField()
-    
+
     def __unicode__(self):
         return self.name
 
@@ -114,17 +120,17 @@
 # using inlineformset_factory.
 class Repository(models.Model):
     name = models.CharField(max_length=25)
-    
+
     def __unicode__(self):
         return self.name
 
 class Revision(models.Model):
     repository = models.ForeignKey(Repository)
     revision = models.CharField(max_length=40)
-    
+
     class Meta:
         unique_together = (("repository", "revision"),)
-    
+
     def __unicode__(self):
         return u"%s (%s)" % (self.revision, unicode(self.repository))
 
@@ -146,7 +152,7 @@
 class Player(models.Model):
     team = models.ForeignKey(Team, null=True)
     name = models.CharField(max_length=100)
-    
+
     def __unicode__(self):
         return self.name
 
@@ -163,6 +169,15 @@
     def __unicode__(self):
         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': """
 
 >>> from datetime import date
@@ -539,7 +554,7 @@
 ...     print book.title
 Les Fleurs du Mal
 
-Test inline formsets where the inline-edited object uses multi-table 
inheritance, thus 
+Test inline formsets where the inline-edited object uses multi-table 
inheritance, thus
 has a non AutoField yet auto-created primary key.
 
 >>> AuthorBooksFormSet3 = inlineformset_factory(Author, AlternateBook, 
 >>> can_delete=False, extra=1)
@@ -676,7 +691,7 @@
 >>> formset.save()
 [<OwnerProfile: Joe Perry is 55>]
 
-# ForeignKey with unique=True should enforce max_num=1 
+# ForeignKey with unique=True should enforce max_num=1
 
 >>> FormSet = inlineformset_factory(Place, Location, can_delete=False)
 >>> formset = FormSet(instance=place)
@@ -874,4 +889,128 @@
 >>> formset.get_queryset()
 [<Player: Bobby>]
 
+# 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.']
 """}


--~--~---------~--~----~------------~-------~--~----~
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To post to this group, send email to django-updates@googlegroups.com
To unsubscribe from this group, send email to 
django-updates+unsubscr...@googlegroups.com
For more options, visit this group at 
http://groups.google.com/group/django-updates?hl=en
-~----------~----~----~----~------~----~------~--~---

Reply via email to