Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

[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...
commit 7bcbc99b9e25aac38266aac1fbaad9323dfaf419 1 parent cd4f12d
Russell Keith-Magee authored May 08, 2009
136  django/forms/models.py
@@ -6,10 +6,10 @@
6 6
 from django.utils.encoding import smart_unicode, force_unicode
7 7
 from django.utils.datastructures import SortedDict
8 8
 from django.utils.text import get_text_list, capfirst
9  
-from django.utils.translation import ugettext_lazy as _
  9
+from django.utils.translation import ugettext_lazy as _, ugettext
10 10
 
11 11
 from util import ValidationError, ErrorList
12  
-from forms import BaseForm, get_declared_fields
  12
+from forms import BaseForm, get_declared_fields, NON_FIELD_ERRORS
13 13
 from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES
14 14
 from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput
15 15
 from widgets import media_property
@@ -225,6 +225,26 @@ def clean(self):
225 225
         return self.cleaned_data
226 226
 
227 227
     def validate_unique(self):
  228
+        unique_checks, date_checks = self._get_unique_checks()
  229
+        form_errors = []
  230
+        bad_fields = set()
  231
+
  232
+        field_errors, global_errors = self._perform_unique_checks(unique_checks)
  233
+        bad_fields.union(field_errors)
  234
+        form_errors.extend(global_errors)
  235
+
  236
+        field_errors, global_errors = self._perform_date_checks(date_checks)
  237
+        bad_fields.union(field_errors)
  238
+        form_errors.extend(global_errors)
  239
+
  240
+        for field_name in bad_fields:
  241
+            del self.cleaned_data[field_name]
  242
+        if form_errors:
  243
+            # Raise the unique together errors since they are considered
  244
+            # form-wide.
  245
+            raise ValidationError(form_errors)
  246
+
  247
+    def _get_unique_checks(self):
228 248
         from django.db.models.fields import FieldDoesNotExist, Field as ModelField
229 249
 
230 250
         # Gather a list of checks to perform. We only perform unique checks
@@ -265,24 +285,8 @@ def validate_unique(self):
265 285
                 date_checks.append(('year', name, f.unique_for_year))
266 286
             if f.unique_for_month and self.cleaned_data.get(f.unique_for_month) is not None:
267 287
                 date_checks.append(('month', name, f.unique_for_month))
  288
+        return unique_checks, date_checks
268 289
 
269  
-        form_errors = []
270  
-        bad_fields = set()
271  
-
272  
-        field_errors, global_errors = self._perform_unique_checks(unique_checks)
273  
-        bad_fields.union(field_errors)
274  
-        form_errors.extend(global_errors)
275  
-
276  
-        field_errors, global_errors = self._perform_date_checks(date_checks)
277  
-        bad_fields.union(field_errors)
278  
-        form_errors.extend(global_errors)
279  
-
280  
-        for field_name in bad_fields:
281  
-            del self.cleaned_data[field_name]
282  
-        if form_errors:
283  
-            # Raise the unique together errors since they are considered
284  
-            # form-wide.
285  
-            raise ValidationError(form_errors)
286 290
 
287 291
     def _perform_unique_checks(self, unique_checks):
288 292
         bad_fields = set()
@@ -497,6 +501,96 @@ def save_m2m():
497 501
             self.save_m2m = save_m2m
498 502
         return self.save_existing_objects(commit) + self.save_new_objects(commit)
499 503
 
  504
+    def clean(self):
  505
+        self.validate_unique()
  506
+
  507
+    def validate_unique(self):
  508
+        # Iterate over the forms so that we can find one with potentially valid
  509
+        # data from which to extract the error checks
  510
+        for form in self.forms:
  511
+            if hasattr(form, 'cleaned_data'):
  512
+                break
  513
+        else:
  514
+            return
  515
+        unique_checks, date_checks = form._get_unique_checks()
  516
+        errors = []
  517
+        # Do each of the unique checks (unique and unique_together)
  518
+        for unique_check in unique_checks:
  519
+            seen_data = set()
  520
+            for form in self.forms:
  521
+                # if the form doesn't have cleaned_data then we ignore it,
  522
+                # it's already invalid
  523
+                if not hasattr(form, "cleaned_data"):
  524
+                    continue
  525
+                # get each of the fields for which we have data on this form
  526
+                if [f for f in unique_check if f in form.cleaned_data and form.cleaned_data[f] is not None]:
  527
+                    # get the data itself
  528
+                    row_data = tuple([form.cleaned_data[field] for field in unique_check])
  529
+                    # if we've aready seen it then we have a uniqueness failure
  530
+                    if row_data in seen_data:
  531
+                        # poke error messages into the right places and mark
  532
+                        # the form as invalid
  533
+                        errors.append(self.get_unique_error_message(unique_check))
  534
+                        form._errors[NON_FIELD_ERRORS] = self.get_form_error()
  535
+                        del form.cleaned_data
  536
+                        break
  537
+                    # mark the data as seen
  538
+                    seen_data.add(row_data)
  539
+        # iterate over each of the date checks now
  540
+        for date_check in date_checks:
  541
+            seen_data = set()
  542
+            lookup, field, unique_for = date_check
  543
+            for form in self.forms:
  544
+                # if the form doesn't have cleaned_data then we ignore it,
  545
+                # it's already invalid
  546
+                if not hasattr(self, 'cleaned_data'):
  547
+                    continue
  548
+                # see if we have data for both fields
  549
+                if (form.cleaned_data and form.cleaned_data[field] is not None
  550
+                    and form.cleaned_data[unique_for] is not None):
  551
+                    # if it's a date lookup we need to get the data for all the fields
  552
+                    if lookup == 'date':
  553
+                        date = form.cleaned_data[unique_for]
  554
+                        date_data = (date.year, date.month, date.day)
  555
+                    # otherwise it's just the attribute on the date/datetime
  556
+                    # object
  557
+                    else:
  558
+                        date_data = (getattr(form.cleaned_data[unique_for], lookup),)
  559
+                    data = (form.cleaned_data[field],) + date_data
  560
+                    # if we've aready seen it then we have a uniqueness failure
  561
+                    if data in seen_data:
  562
+                        # poke error messages into the right places and mark
  563
+                        # the form as invalid
  564
+                        errors.append(self.get_date_error_message(date_check))
  565
+                        form._errors[NON_FIELD_ERRORS] = self.get_form_error()
  566
+                        del form.cleaned_data
  567
+                        break
  568
+                    seen_data.add(data)
  569
+        if errors:
  570
+            raise ValidationError(errors)
  571
+
  572
+    def get_unique_error_message(self, unique_check):
  573
+        if len(unique_check) == 1:
  574
+            return ugettext("Please correct the duplicate data for %(field)s.") % {
  575
+                "field": unique_check[0],
  576
+            }
  577
+        else:
  578
+            return ugettext("Please correct the duplicate data for %(field)s, "
  579
+                "which must be unique.") % {
  580
+                    "field": get_text_list(unique_check, _("and")),
  581
+                }
  582
+
  583
+    def get_date_error_message(self, date_check):
  584
+        return ugettext("Please correct the duplicate data for %(field_name)s "
  585
+            "which must be unique for the %(lookup)s in %(date_field)s.") % {
  586
+            'field_name': date_check[1],
  587
+            'date_field': date_check[2],
  588
+            'lookup': unicode(date_check[0]),
  589
+        }
  590
+
  591
+    def get_form_error(self):
  592
+        return ugettext("Please correct the duplicate values below.")
  593
+
500 594
     def save_existing_objects(self, commit=True):
501 595
         self.changed_objects = []
502 596
         self.deleted_objects = []
@@ -629,6 +723,10 @@ def add_fields(self, form, index):
629 723
                 label=getattr(form.fields.get(self.fk.name), 'label', capfirst(self.fk.verbose_name))
630 724
             )
631 725
 
  726
+    def get_unique_error_message(self, unique_check):
  727
+        unique_check = [field for field in unique_check if field != self.fk.name]
  728
+        return super(BaseInlineFormSet, self).get_unique_error_message(unique_check)
  729
+
632 730
 def _get_foreign_key(parent_model, model, fk_name=None):
633 731
     """
634 732
     Finds and returns the ForeignKey from model to parent if there is one.
83  docs/topics/forms/modelforms.txt
@@ -45,61 +45,61 @@ the full list of conversions:
45 45
     Model field                      Form field
46 46
     ===============================  ========================================
47 47
     ``AutoField``                    Not represented in the form
48  
-    
  48
+
49 49
     ``BooleanField``                 ``BooleanField``
50  
-    
  50
+
51 51
     ``CharField``                    ``CharField`` with ``max_length`` set to
52 52
                                      the model field's ``max_length``
53  
-    
  53
+
54 54
     ``CommaSeparatedIntegerField``   ``CharField``
55  
-    
  55
+
56 56
     ``DateField``                    ``DateField``
57  
-    
  57
+
58 58
     ``DateTimeField``                ``DateTimeField``
59  
-    
  59
+
60 60
     ``DecimalField``                 ``DecimalField``
61  
-    
  61
+
62 62
     ``EmailField``                   ``EmailField``
63  
-    
  63
+
64 64
     ``FileField``                    ``FileField``
65  
-    
  65
+
66 66
     ``FilePathField``                ``CharField``
67  
-    
  67
+
68 68
     ``FloatField``                   ``FloatField``
69  
-    
  69
+
70 70
     ``ForeignKey``                   ``ModelChoiceField`` (see below)
71  
-    
  71
+
72 72
     ``ImageField``                   ``ImageField``
73  
-    
  73
+
74 74
     ``IntegerField``                 ``IntegerField``
75  
-    
  75
+
76 76
     ``IPAddressField``               ``IPAddressField``
77  
-    
  77
+
78 78
     ``ManyToManyField``              ``ModelMultipleChoiceField`` (see
79 79
                                      below)
80  
-    
  80
+
81 81
     ``NullBooleanField``             ``CharField``
82  
-    
  82
+
83 83
     ``PhoneNumberField``             ``USPhoneNumberField``
84 84
                                      (from ``django.contrib.localflavor.us``)
85  
-    
  85
+
86 86
     ``PositiveIntegerField``         ``IntegerField``
87  
-    
  87
+
88 88
     ``PositiveSmallIntegerField``    ``IntegerField``
89  
-    
  89
+
90 90
     ``SlugField``                    ``SlugField``
91  
-    
  91
+
92 92
     ``SmallIntegerField``            ``IntegerField``
93  
-    
94  
-    ``TextField``                    ``CharField`` with 
  93
+
  94
+    ``TextField``                    ``CharField`` with
95 95
                                      ``widget=forms.Textarea``
96  
-    
  96
+
97 97
     ``TimeField``                    ``TimeField``
98  
-    
  98
+
99 99
     ``URLField``                     ``URLField`` with ``verify_exists`` set
100 100
                                      to the model field's ``verify_exists``
101  
-    
102  
-    ``XMLField``                     ``CharField`` with 
  101
+
  102
+    ``XMLField``                     ``CharField`` with
103 103
                                      ``widget=forms.Textarea``
104 104
     ===============================  ========================================
105 105
 
@@ -455,7 +455,7 @@ queryset that includes all objects in the model (e.g.,
455 455
 
456 456
 Alternatively, you can create a subclass that sets ``self.queryset`` in
457 457
 ``__init__``::
458  
-    
  458
+
459 459
     from django.forms.models import BaseModelFormSet
460 460
 
461 461
     class BaseAuthorFormSet(BaseModelFormSet):
@@ -483,6 +483,22 @@ exclude::
483 483
 
484 484
 .. _saving-objects-in-the-formset:
485 485
 
  486
+Overriding clean() method
  487
+-------------------------
  488
+
  489
+You can override the ``clean()`` method to provide custom validation to
  490
+the whole formset at once. By default, the ``clean()`` method will validate
  491
+that none of the data in the formsets violate the unique constraints on your
  492
+model (both field ``unique`` and model ``unique_together``). To maintain this
  493
+default behavior be sure you call the parent's ``clean()`` method::
  494
+
  495
+    class MyModelFormSet(BaseModelFormSet):
  496
+        def clean(self):
  497
+            super(MyModelFormSet, self).clean()
  498
+            # example custom validation across forms in the formset:
  499
+            for form in self.forms:
  500
+                # your custom formset validation
  501
+
486 502
 Saving objects in the formset
487 503
 -----------------------------
488 504
 
@@ -567,6 +583,17 @@ than that of a "normal" formset. The only difference is that we call
567 583
 ``formset.save()`` to save the data into the database. (This was described
568 584
 above, in :ref:`saving-objects-in-the-formset`.)
569 585
 
  586
+
  587
+Overiding ``clean()`` on a ``model_formset``
  588
+--------------------------------------------
  589
+
  590
+Just like with ``ModelForms``, by default the ``clean()`` method of a
  591
+``model_formset`` will validate that none of the items in the formset validate
  592
+the unique constraints on your model(either unique or unique_together).  If you
  593
+want to overide the ``clean()`` method on a ``model_formset`` and maintain this
  594
+validation, you must call the parent classes ``clean`` method.
  595
+
  596
+
570 597
 Using a custom queryset
571 598
 ~~~~~~~~~~~~~~~~~~~~~~~
572 599
 
167  tests/modeltests/model_formsets/models.py
@@ -25,9 +25,15 @@ class Book(models.Model):
25 25
     author = models.ForeignKey(Author)
26 26
     title = models.CharField(max_length=100)
27 27
 
  28
+    class Meta:
  29
+        unique_together = (
  30
+            ('author', 'title'),
  31
+        )
  32
+        ordering = ['id']
  33
+
28 34
     def __unicode__(self):
29 35
         return self.title
30  
-    
  36
+
31 37
 class BookWithCustomPK(models.Model):
32 38
     my_pk = models.DecimalField(max_digits=5, decimal_places=0, primary_key=True)
33 39
     author = models.ForeignKey(Author)
@@ -35,13 +41,13 @@ class BookWithCustomPK(models.Model):
35 41
 
36 42
     def __unicode__(self):
37 43
         return u'%s: %s' % (self.my_pk, self.title)
38  
-    
  44
+
39 45
 class AlternateBook(Book):
40 46
     notes = models.CharField(max_length=100)
41  
-    
  47
+
42 48
     def __unicode__(self):
43 49
         return u'%s - %s' % (self.title, self.notes)
44  
-    
  50
+
45 51
 class AuthorMeeting(models.Model):
46 52
     name = models.CharField(max_length=100)
47 53
     authors = models.ManyToManyField(Author)
@@ -60,7 +66,7 @@ class CustomPrimaryKey(models.Model):
60 66
 class Place(models.Model):
61 67
     name = models.CharField(max_length=50)
62 68
     city = models.CharField(max_length=50)
63  
-    
  69
+
64 70
     def __unicode__(self):
65 71
         return self.name
66 72
 
@@ -68,7 +74,7 @@ class Owner(models.Model):
68 74
     auto_id = models.AutoField(primary_key=True)
69 75
     name = models.CharField(max_length=100)
70 76
     place = models.ForeignKey(Place)
71  
-    
  77
+
72 78
     def __unicode__(self):
73 79
         return "%s at %s" % (self.name, self.place)
74 80
 
@@ -81,13 +87,13 @@ class Location(models.Model):
81 87
 class OwnerProfile(models.Model):
82 88
     owner = models.OneToOneField(Owner, primary_key=True)
83 89
     age = models.PositiveIntegerField()
84  
-    
  90
+
85 91
     def __unicode__(self):
86 92
         return "%s is %d" % (self.owner.name, self.age)
87 93
 
88 94
 class Restaurant(Place):
89 95
     serves_pizza = models.BooleanField()
90  
-    
  96
+
91 97
     def __unicode__(self):
92 98
         return self.name
93 99
 
@@ -114,17 +120,17 @@ class MexicanRestaurant(Restaurant):
114 120
 # using inlineformset_factory.
115 121
 class Repository(models.Model):
116 122
     name = models.CharField(max_length=25)
117  
-    
  123
+
118 124
     def __unicode__(self):
119 125
         return self.name
120 126
 
121 127
 class Revision(models.Model):
122 128
     repository = models.ForeignKey(Repository)
123 129
     revision = models.CharField(max_length=40)
124  
-    
  130
+
125 131
     class Meta:
126 132
         unique_together = (("repository", "revision"),)
127  
-    
  133
+
128 134
     def __unicode__(self):
129 135
         return u"%s (%s)" % (self.revision, unicode(self.repository))
130 136
 
@@ -146,7 +152,7 @@ class Team(models.Model):
146 152
 class Player(models.Model):
147 153
     team = models.ForeignKey(Team, null=True)
148 154
     name = models.CharField(max_length=100)
149  
-    
  155
+
150 156
     def __unicode__(self):
151 157
         return self.name
152 158
 
@@ -163,6 +169,15 @@ class Poem(models.Model):
163 169
     def __unicode__(self):
164 170
         return self.name
165 171
 
  172
+class Post(models.Model):
  173
+    title = models.CharField(max_length=50, unique_for_date='posted', blank=True)
  174
+    slug = models.CharField(max_length=50, unique_for_year='posted', blank=True)
  175
+    subtitle = models.CharField(max_length=50, unique_for_month='posted', blank=True)
  176
+    posted = models.DateField()
  177
+
  178
+    def __unicode__(self):
  179
+        return self.name
  180
+
166 181
 __test__ = {'API_TESTS': """
167 182
 
168 183
 >>> from datetime import date
@@ -539,7 +554,7 @@ def __unicode__(self):
539 554
 ...     print book.title
540 555
 Les Fleurs du Mal
541 556
 
542  
-Test inline formsets where the inline-edited object uses multi-table inheritance, thus 
  557
+Test inline formsets where the inline-edited object uses multi-table inheritance, thus
543 558
 has a non AutoField yet auto-created primary key.
544 559
 
545 560
 >>> AuthorBooksFormSet3 = inlineformset_factory(Author, AlternateBook, can_delete=False, extra=1)
@@ -676,7 +691,7 @@ def __unicode__(self):
676 691
 >>> formset.save()
677 692
 [<OwnerProfile: Joe Perry is 55>]
678 693
 
679  
-# ForeignKey with unique=True should enforce max_num=1 
  694
+# ForeignKey with unique=True should enforce max_num=1
680 695
 
681 696
 >>> FormSet = inlineformset_factory(Place, Location, can_delete=False)
682 697
 >>> formset = FormSet(instance=place)
@@ -874,4 +889,128 @@ def __unicode__(self):
874 889
 >>> formset.get_queryset()
875 890
 [<Player: Bobby>]
876 891
 
  892
+# Prevent duplicates from within the same formset
  893
+>>> FormSet = modelformset_factory(Product, extra=2)
  894
+>>> data = {
  895
+...     'form-TOTAL_FORMS': 2,
  896
+...     'form-INITIAL_FORMS': 0,
  897
+...     'form-0-slug': 'red_car',
  898
+...     'form-1-slug': 'red_car',
  899
+... }
  900
+>>> formset = FormSet(data)
  901
+>>> formset.is_valid()
  902
+False
  903
+>>> formset._non_form_errors
  904
+[u'Please correct the duplicate data for slug.']
  905
+
  906
+>>> FormSet = modelformset_factory(Price, extra=2)
  907
+>>> data = {
  908
+...     'form-TOTAL_FORMS': 2,
  909
+...     'form-INITIAL_FORMS': 0,
  910
+...     'form-0-price': '25',
  911
+...     'form-0-quantity': '7',
  912
+...     'form-1-price': '25',
  913
+...     'form-1-quantity': '7',
  914
+... }
  915
+>>> formset = FormSet(data)
  916
+>>> formset.is_valid()
  917
+False
  918
+>>> formset._non_form_errors
  919
+[u'Please correct the duplicate data for price and quantity, which must be unique.']
  920
+
  921
+# only the price field is specified, this should skip any unique checks since the unique_together is not fulfilled.
  922
+# this will fail with a KeyError if broken.
  923
+>>> FormSet = modelformset_factory(Price, fields=("price",), extra=2)
  924
+>>> data = {
  925
+...     'form-TOTAL_FORMS': '2',
  926
+...     'form-INITIAL_FORMS': '0',
  927
+...     'form-0-price': '24',
  928
+...     'form-1-price': '24',
  929
+... }
  930
+>>> formset = FormSet(data)
  931
+>>> formset.is_valid()
  932
+True
  933
+
  934
+>>> FormSet = inlineformset_factory(Author, Book, extra=0)
  935
+>>> author = Author.objects.order_by('id')[0]
  936
+>>> book_ids = author.book_set.values_list('id', flat=True)
  937
+>>> data = {
  938
+...     'book_set-TOTAL_FORMS': '2',
  939
+...     'book_set-INITIAL_FORMS': '2',
  940
+...
  941
+...     'book_set-0-title': 'The 2008 Election',
  942
+...     'book_set-0-author': str(author.id),
  943
+...     'book_set-0-id': str(book_ids[0]),
  944
+...
  945
+...     'book_set-1-title': 'The 2008 Election',
  946
+...     'book_set-1-author': str(author.id),
  947
+...     'book_set-1-id': str(book_ids[1]),
  948
+... }
  949
+>>> formset = FormSet(data=data, instance=author)
  950
+>>> formset.is_valid()
  951
+False
  952
+>>> formset._non_form_errors
  953
+[u'Please correct the duplicate data for title.']
  954
+>>> formset.errors
  955
+[{}, {'__all__': u'Please correct the duplicate values below.'}]
  956
+
  957
+>>> FormSet = modelformset_factory(Post, extra=2)
  958
+>>> data = {
  959
+...     'form-TOTAL_FORMS': '2',
  960
+...     'form-INITIAL_FORMS': '0',
  961
+...
  962
+...     'form-0-title': 'blah',
  963
+...     'form-0-slug': 'Morning',
  964
+...     'form-0-subtitle': 'foo',
  965
+...     'form-0-posted': '2009-01-01',
  966
+...     'form-1-title': 'blah',
  967
+...     'form-1-slug': 'Morning in Prague',
  968
+...     'form-1-subtitle': 'rawr',
  969
+...     'form-1-posted': '2009-01-01'
  970
+... }
  971
+>>> formset = FormSet(data)
  972
+>>> formset.is_valid()
  973
+False
  974
+>>> formset._non_form_errors
  975
+[u'Please correct the duplicate data for title which must be unique for the date in posted.']
  976
+>>> formset.errors
  977
+[{}, {'__all__': u'Please correct the duplicate values below.'}]
  978
+
  979
+>>> data = {
  980
+...     'form-TOTAL_FORMS': '2',
  981
+...     'form-INITIAL_FORMS': '0',
  982
+...
  983
+...     'form-0-title': 'foo',
  984
+...     'form-0-slug': 'Morning in Prague',
  985
+...     'form-0-subtitle': 'foo',
  986
+...     'form-0-posted': '2009-01-01',
  987
+...     'form-1-title': 'blah',
  988
+...     'form-1-slug': 'Morning in Prague',
  989
+...     'form-1-subtitle': 'rawr',
  990
+...     'form-1-posted': '2009-08-02'
  991
+... }
  992
+>>> formset = FormSet(data)
  993
+>>> formset.is_valid()
  994
+False
  995
+>>> formset._non_form_errors
  996
+[u'Please correct the duplicate data for slug which must be unique for the year in posted.']
  997
+
  998
+>>> data = {
  999
+...     'form-TOTAL_FORMS': '2',
  1000
+...     'form-INITIAL_FORMS': '0',
  1001
+...
  1002
+...     'form-0-title': 'foo',
  1003
+...     'form-0-slug': 'Morning in Prague',
  1004
+...     'form-0-subtitle': 'rawr',
  1005
+...     'form-0-posted': '2008-08-01',
  1006
+...     'form-1-title': 'blah',
  1007
+...     'form-1-slug': 'Prague',
  1008
+...     'form-1-subtitle': 'rawr',
  1009
+...     'form-1-posted': '2009-08-02'
  1010
+... }
  1011
+>>> formset = FormSet(data)
  1012
+>>> formset.is_valid()
  1013
+False
  1014
+>>> formset._non_form_errors
  1015
+[u'Please correct the duplicate data for subtitle which must be unique for the month in posted.']
877 1016
 """}

0 notes on commit 7bcbc99

Please sign in to comment.
Something went wrong with that request. Please try again.