Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #9493 -- Corrected error handling of formsets that violate uniq…

…ue 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
  • Loading branch information...
commit f259494f827b24228bbbadb1d118a09f8e6329ac 1 parent eb81d5a
Russell Keith-Magee authored May 07, 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
@@ -231,6 +231,26 @@ def clean(self):
231 231
         return self.cleaned_data
232 232
 
233 233
     def validate_unique(self):
  234
+        unique_checks, date_checks = self._get_unique_checks()
  235
+        form_errors = []
  236
+        bad_fields = set()
  237
+
  238
+        field_errors, global_errors = self._perform_unique_checks(unique_checks)
  239
+        bad_fields.union(field_errors)
  240
+        form_errors.extend(global_errors)
  241
+
  242
+        field_errors, global_errors = self._perform_date_checks(date_checks)
  243
+        bad_fields.union(field_errors)
  244
+        form_errors.extend(global_errors)
  245
+
  246
+        for field_name in bad_fields:
  247
+            del self.cleaned_data[field_name]
  248
+        if form_errors:
  249
+            # Raise the unique together errors since they are considered
  250
+            # form-wide.
  251
+            raise ValidationError(form_errors)
  252
+
  253
+    def _get_unique_checks(self):
234 254
         from django.db.models.fields import FieldDoesNotExist, Field as ModelField
235 255
 
236 256
         # Gather a list of checks to perform. We only perform unique checks
@@ -271,24 +291,8 @@ def validate_unique(self):
271 291
                 date_checks.append(('year', name, f.unique_for_year))
272 292
             if f.unique_for_month and self.cleaned_data.get(f.unique_for_month) is not None:
273 293
                 date_checks.append(('month', name, f.unique_for_month))
  294
+        return unique_checks, date_checks
274 295
 
275  
-        form_errors = []
276  
-        bad_fields = set()
277  
-
278  
-        field_errors, global_errors = self._perform_unique_checks(unique_checks)
279  
-        bad_fields.union(field_errors)
280  
-        form_errors.extend(global_errors)
281  
-
282  
-        field_errors, global_errors = self._perform_date_checks(date_checks)
283  
-        bad_fields.union(field_errors)
284  
-        form_errors.extend(global_errors)
285  
-
286  
-        for field_name in bad_fields:
287  
-            del self.cleaned_data[field_name]
288  
-        if form_errors:
289  
-            # Raise the unique together errors since they are considered
290  
-            # form-wide.
291  
-            raise ValidationError(form_errors)
292 296
 
293 297
     def _perform_unique_checks(self, unique_checks):
294 298
         bad_fields = set()
@@ -504,6 +508,96 @@ def save_m2m():
504 508
             self.save_m2m = save_m2m
505 509
         return self.save_existing_objects(commit) + self.save_new_objects(commit)
506 510
 
  511
+    def clean(self):
  512
+        self.validate_unique()
  513
+
  514
+    def validate_unique(self):
  515
+        # Iterate over the forms so that we can find one with potentially valid
  516
+        # data from which to extract the error checks
  517
+        for form in self.forms:
  518
+            if hasattr(form, 'cleaned_data'):
  519
+                break
  520
+        else:
  521
+            return
  522
+        unique_checks, date_checks = form._get_unique_checks()
  523
+        errors = []
  524
+        # Do each of the unique checks (unique and unique_together)
  525
+        for unique_check in unique_checks:
  526
+            seen_data = set()
  527
+            for form in self.forms:
  528
+                # if the form doesn't have cleaned_data then we ignore it,
  529
+                # it's already invalid
  530
+                if not hasattr(form, "cleaned_data"):
  531
+                    continue
  532
+                # get each of the fields for which we have data on this form
  533
+                if [f for f in unique_check if f in form.cleaned_data and form.cleaned_data[f] is not None]:
  534
+                    # get the data itself
  535
+                    row_data = tuple([form.cleaned_data[field] for field in unique_check])
  536
+                    # if we've aready seen it then we have a uniqueness failure
  537
+                    if row_data in seen_data:
  538
+                        # poke error messages into the right places and mark
  539
+                        # the form as invalid
  540
+                        errors.append(self.get_unique_error_message(unique_check))
  541
+                        form._errors[NON_FIELD_ERRORS] = self.get_form_error()
  542
+                        del form.cleaned_data
  543
+                        break
  544
+                    # mark the data as seen
  545
+                    seen_data.add(row_data)
  546
+        # iterate over each of the date checks now
  547
+        for date_check in date_checks:
  548
+            seen_data = set()
  549
+            lookup, field, unique_for = date_check
  550
+            for form in self.forms:
  551
+                # if the form doesn't have cleaned_data then we ignore it,
  552
+                # it's already invalid
  553
+                if not hasattr(self, 'cleaned_data'):
  554
+                    continue
  555
+                # see if we have data for both fields
  556
+                if (form.cleaned_data and form.cleaned_data[field] is not None
  557
+                    and form.cleaned_data[unique_for] is not None):
  558
+                    # if it's a date lookup we need to get the data for all the fields
  559
+                    if lookup == 'date':
  560
+                        date = form.cleaned_data[unique_for]
  561
+                        date_data = (date.year, date.month, date.day)
  562
+                    # otherwise it's just the attribute on the date/datetime
  563
+                    # object
  564
+                    else:
  565
+                        date_data = (getattr(form.cleaned_data[unique_for], lookup),)
  566
+                    data = (form.cleaned_data[field],) + date_data
  567
+                    # if we've aready seen it then we have a uniqueness failure
  568
+                    if data in seen_data:
  569
+                        # poke error messages into the right places and mark
  570
+                        # the form as invalid
  571
+                        errors.append(self.get_date_error_message(date_check))
  572
+                        form._errors[NON_FIELD_ERRORS] = self.get_form_error()
  573
+                        del form.cleaned_data
  574
+                        break
  575
+                    seen_data.add(data)
  576
+        if errors:
  577
+            raise ValidationError(errors)
  578
+
  579
+    def get_unique_error_message(self, unique_check):
  580
+        if len(unique_check) == 1:
  581
+            return ugettext("Please correct the duplicate data for %(field)s.") % {
  582
+                "field": unique_check[0],
  583
+            }
  584
+        else:
  585
+            return ugettext("Please correct the duplicate data for %(field)s, "
  586
+                "which must be unique.") % {
  587
+                    "field": get_text_list(unique_check, _("and")),
  588
+                }
  589
+
  590
+    def get_date_error_message(self, date_check):
  591
+        return ugettext("Please correct the duplicate data for %(field_name)s "
  592
+            "which must be unique for the %(lookup)s in %(date_field)s.") % {
  593
+            'field_name': date_check[1],
  594
+            'date_field': date_check[2],
  595
+            'lookup': unicode(date_check[0]),
  596
+        }
  597
+
  598
+    def get_form_error(self):
  599
+        return ugettext("Please correct the duplicate values below.")
  600
+
507 601
     def save_existing_objects(self, commit=True):
508 602
         self.changed_objects = []
509 603
         self.deleted_objects = []
@@ -657,6 +751,10 @@ def add_fields(self, form, index):
657 751
                 label=getattr(form.fields.get(self.fk.name), 'label', capfirst(self.fk.verbose_name))
658 752
             )
659 753
 
  754
+    def get_unique_error_message(self, unique_check):
  755
+        unique_check = [field for field in unique_check if field != self.fk.name]
  756
+        return super(BaseInlineFormSet, self).get_unique_error_message(unique_check)
  757
+
660 758
 def _get_foreign_key(parent_model, model, fk_name=None):
661 759
     """
662 760
     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
 
@@ -487,7 +487,7 @@ queryset that includes all objects in the model (e.g.,
487 487
 
488 488
 Alternatively, you can create a subclass that sets ``self.queryset`` in
489 489
 ``__init__``::
490  
-    
  490
+
491 491
     from django.forms.models import BaseModelFormSet
492 492
 
493 493
     class BaseAuthorFormSet(BaseModelFormSet):
@@ -515,6 +515,22 @@ exclude::
515 515
 
516 516
 .. _saving-objects-in-the-formset:
517 517
 
  518
+Overriding clean() method
  519
+-------------------------
  520
+
  521
+You can override the ``clean()`` method to provide custom validation to
  522
+the whole formset at once. By default, the ``clean()`` method will validate
  523
+that none of the data in the formsets violate the unique constraints on your
  524
+model (both field ``unique`` and model ``unique_together``). To maintain this
  525
+default behavior be sure you call the parent's ``clean()`` method::
  526
+
  527
+    class MyModelFormSet(BaseModelFormSet):
  528
+        def clean(self):
  529
+            super(MyModelFormSet, self).clean()
  530
+            # example custom validation across forms in the formset:
  531
+            for form in self.forms:
  532
+                # your custom formset validation
  533
+
518 534
 Saving objects in the formset
519 535
 -----------------------------
520 536
 
@@ -599,6 +615,17 @@ than that of a "normal" formset. The only difference is that we call
599 615
 ``formset.save()`` to save the data into the database. (This was described
600 616
 above, in :ref:`saving-objects-in-the-formset`.)
601 617
 
  618
+
  619
+Overiding ``clean()`` on a ``model_formset``
  620
+--------------------------------------------
  621
+
  622
+Just like with ``ModelForms``, by default the ``clean()`` method of a
  623
+``model_formset`` will validate that none of the items in the formset validate
  624
+the unique constraints on your model(either unique or unique_together).  If you
  625
+want to overide the ``clean()`` method on a ``model_formset`` and maintain this
  626
+validation, you must call the parent classes ``clean`` method.
  627
+
  628
+
602 629
 Using a custom queryset
603 630
 ~~~~~~~~~~~~~~~~~~~~~~~
604 631
 
148  tests/modeltests/model_formsets/models.py
@@ -23,6 +23,12 @@ class Book(models.Model):
23 23
     author = models.ForeignKey(Author)
24 24
     title = models.CharField(max_length=100)
25 25
 
  26
+    class Meta:
  27
+        unique_together = (
  28
+            ('author', 'title'),
  29
+        )
  30
+        ordering = ['id']
  31
+
26 32
     def __unicode__(self):
27 33
         return self.title
28 34
 
@@ -58,7 +64,7 @@ class CustomPrimaryKey(models.Model):
58 64
 class Place(models.Model):
59 65
     name = models.CharField(max_length=50)
60 66
     city = models.CharField(max_length=50)
61  
-    
  67
+
62 68
     def __unicode__(self):
63 69
         return self.name
64 70
 
@@ -85,7 +91,7 @@ def __unicode__(self):
85 91
 
86 92
 class Restaurant(Place):
87 93
     serves_pizza = models.BooleanField()
88  
-    
  94
+
89 95
     def __unicode__(self):
90 96
         return self.name
91 97
 
@@ -166,6 +172,15 @@ class Poem(models.Model):
166 172
     def __unicode__(self):
167 173
         return self.name
168 174
 
  175
+class Post(models.Model):
  176
+    title = models.CharField(max_length=50, unique_for_date='posted', blank=True)
  177
+    slug = models.CharField(max_length=50, unique_for_year='posted', blank=True)
  178
+    subtitle = models.CharField(max_length=50, unique_for_month='posted', blank=True)
  179
+    posted = models.DateField()
  180
+
  181
+    def __unicode__(self):
  182
+        return self.name
  183
+
169 184
 __test__ = {'API_TESTS': """
170 185
 
171 186
 >>> from datetime import date
@@ -573,7 +588,7 @@ def __unicode__(self):
573 588
 ...     print book.title
574 589
 Les Fleurs du Mal
575 590
 
576  
-Test inline formsets where the inline-edited object uses multi-table inheritance, thus 
  591
+Test inline formsets where the inline-edited object uses multi-table inheritance, thus
577 592
 has a non AutoField yet auto-created primary key.
578 593
 
579 594
 >>> AuthorBooksFormSet3 = inlineformset_factory(Author, AlternateBook, can_delete=False, extra=1)
@@ -740,7 +755,7 @@ def __unicode__(self):
740 755
 >>> formset.save()
741 756
 [<OwnerProfile: Joe Perry is 55>]
742 757
 
743  
-# ForeignKey with unique=True should enforce max_num=1 
  758
+# ForeignKey with unique=True should enforce max_num=1
744 759
 
745 760
 >>> FormSet = inlineformset_factory(Place, Location, can_delete=False)
746 761
 >>> formset = FormSet(instance=place)
@@ -943,4 +958,129 @@ def __unicode__(self):
943 958
 >>> FormSet = modelformset_factory(ClassyMexicanRestaurant, fields=["tacos_are_yummy"])
944 959
 >>> sorted(FormSet().forms[0].fields.keys())
945 960
 ['restaurant', 'tacos_are_yummy']
  961
+
  962
+# Prevent duplicates from within the same formset
  963
+>>> FormSet = modelformset_factory(Product, extra=2)
  964
+>>> data = {
  965
+...     'form-TOTAL_FORMS': 2,
  966
+...     'form-INITIAL_FORMS': 0,
  967
+...     'form-0-slug': 'red_car',
  968
+...     'form-1-slug': 'red_car',
  969
+... }
  970
+>>> formset = FormSet(data)
  971
+>>> formset.is_valid()
  972
+False
  973
+>>> formset._non_form_errors
  974
+[u'Please correct the duplicate data for slug.']
  975
+
  976
+>>> FormSet = modelformset_factory(Price, extra=2)
  977
+>>> data = {
  978
+...     'form-TOTAL_FORMS': 2,
  979
+...     'form-INITIAL_FORMS': 0,
  980
+...     'form-0-price': '25',
  981
+...     'form-0-quantity': '7',
  982
+...     'form-1-price': '25',
  983
+...     'form-1-quantity': '7',
  984
+... }
  985
+>>> formset = FormSet(data)
  986
+>>> formset.is_valid()
  987
+False
  988
+>>> formset._non_form_errors
  989
+[u'Please correct the duplicate data for price and quantity, which must be unique.']
  990
+
  991
+# only the price field is specified, this should skip any unique checks since the unique_together is not fulfilled.
  992
+# this will fail with a KeyError if broken.
  993
+>>> FormSet = modelformset_factory(Price, fields=("price",), extra=2)
  994
+>>> data = {
  995
+...     'form-TOTAL_FORMS': '2',
  996
+...     'form-INITIAL_FORMS': '0',
  997
+...     'form-0-price': '24',
  998
+...     'form-1-price': '24',
  999
+... }
  1000
+>>> formset = FormSet(data)
  1001
+>>> formset.is_valid()
  1002
+True
  1003
+
  1004
+>>> FormSet = inlineformset_factory(Author, Book, extra=0)
  1005
+>>> author = Author.objects.order_by('id')[0]
  1006
+>>> book_ids = author.book_set.values_list('id', flat=True)
  1007
+>>> data = {
  1008
+...     'book_set-TOTAL_FORMS': '2',
  1009
+...     'book_set-INITIAL_FORMS': '2',
  1010
+...
  1011
+...     'book_set-0-title': 'The 2008 Election',
  1012
+...     'book_set-0-author': str(author.id),
  1013
+...     'book_set-0-id': str(book_ids[0]),
  1014
+...
  1015
+...     'book_set-1-title': 'The 2008 Election',
  1016
+...     'book_set-1-author': str(author.id),
  1017
+...     'book_set-1-id': str(book_ids[1]),
  1018
+... }
  1019
+>>> formset = FormSet(data=data, instance=author)
  1020
+>>> formset.is_valid()
  1021
+False
  1022
+>>> formset._non_form_errors
  1023
+[u'Please correct the duplicate data for title.']
  1024
+>>> formset.errors
  1025
+[{}, {'__all__': u'Please correct the duplicate values below.'}]
  1026
+
  1027
+>>> FormSet = modelformset_factory(Post, extra=2)
  1028
+>>> data = {
  1029
+...     'form-TOTAL_FORMS': '2',
  1030
+...     'form-INITIAL_FORMS': '0',
  1031
+...
  1032
+...     'form-0-title': 'blah',
  1033
+...     'form-0-slug': 'Morning',
  1034
+...     'form-0-subtitle': 'foo',
  1035
+...     'form-0-posted': '2009-01-01',
  1036
+...     'form-1-title': 'blah',
  1037
+...     'form-1-slug': 'Morning in Prague',
  1038
+...     'form-1-subtitle': 'rawr',
  1039
+...     'form-1-posted': '2009-01-01'
  1040
+... }
  1041
+>>> formset = FormSet(data)
  1042
+>>> formset.is_valid()
  1043
+False
  1044
+>>> formset._non_form_errors
  1045
+[u'Please correct the duplicate data for title which must be unique for the date in posted.']
  1046
+>>> formset.errors
  1047
+[{}, {'__all__': u'Please correct the duplicate values below.'}]
  1048
+
  1049
+>>> data = {
  1050
+...     'form-TOTAL_FORMS': '2',
  1051
+...     'form-INITIAL_FORMS': '0',
  1052
+...
  1053
+...     'form-0-title': 'foo',
  1054
+...     'form-0-slug': 'Morning in Prague',
  1055
+...     'form-0-subtitle': 'foo',
  1056
+...     'form-0-posted': '2009-01-01',
  1057
+...     'form-1-title': 'blah',
  1058
+...     'form-1-slug': 'Morning in Prague',
  1059
+...     'form-1-subtitle': 'rawr',
  1060
+...     'form-1-posted': '2009-08-02'
  1061
+... }
  1062
+>>> formset = FormSet(data)
  1063
+>>> formset.is_valid()
  1064
+False
  1065
+>>> formset._non_form_errors
  1066
+[u'Please correct the duplicate data for slug which must be unique for the year in posted.']
  1067
+
  1068
+>>> data = {
  1069
+...     'form-TOTAL_FORMS': '2',
  1070
+...     'form-INITIAL_FORMS': '0',
  1071
+...
  1072
+...     'form-0-title': 'foo',
  1073
+...     'form-0-slug': 'Morning in Prague',
  1074
+...     'form-0-subtitle': 'rawr',
  1075
+...     'form-0-posted': '2008-08-01',
  1076
+...     'form-1-title': 'blah',
  1077
+...     'form-1-slug': 'Prague',
  1078
+...     'form-1-subtitle': 'rawr',
  1079
+...     'form-1-posted': '2009-08-02'
  1080
+... }
  1081
+>>> formset = FormSet(data)
  1082
+>>> formset.is_valid()
  1083
+False
  1084
+>>> formset._non_form_errors
  1085
+[u'Please correct the duplicate data for subtitle which must be unique for the month in posted.']
946 1086
 """}

0 notes on commit f259494

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