Code

Ticket #8209: 8209_modelform_unique_validation.1.diff

File 8209_modelform_unique_validation.1.diff, 11.5 KB (added by brosner, 6 years ago)

added a patch for review

Line 
1diff --git a/django/forms/models.py b/django/forms/models.py
2index 4563ace..77f4a36 100644
3--- a/django/forms/models.py
4+++ b/django/forms/models.py
5@@ -3,9 +3,10 @@ Helper functions for creating Form classes from Django models
6 and database field objects.
7 """
8 
9-from django.utils.translation import ugettext_lazy as _
10 from django.utils.encoding import smart_unicode
11 from django.utils.datastructures import SortedDict
12+from django.utils.text import get_text_list
13+from django.utils.translation import ugettext_lazy as _
14 
15 from util import ValidationError, ErrorList
16 from forms import BaseForm, get_declared_fields
17@@ -20,6 +21,11 @@ __all__ = (
18     'ModelMultipleChoiceField',
19 )
20 
21+try:
22+    any
23+except NameError:
24+    from django.utils.itercompat import any # pre-2.5
25+
26 def save_instance(form, instance, fields=None, fail_message='saved',
27                   commit=True, exclude=None):
28     """
29@@ -202,6 +208,47 @@ class BaseModelForm(BaseForm):
30             object_data.update(initial)
31         super(BaseModelForm, self).__init__(data, files, auto_id, prefix, object_data,
32                                             error_class, label_suffix, empty_permitted)
33+    def clean(self):
34+        self.validate_unique()
35+        return self.cleaned_data
36+   
37+    def validate_unique(self):
38+        from django.db.models.fields import FieldDoesNotExist
39+        unique_checks = list(self.instance._meta.unique_together[:])
40+        form_errors = []
41+        for name, field in self.fields.items():
42+            try:
43+                f = self.instance._meta.get_field_by_name(name)[0]
44+            except FieldDoesNotExist:
45+                # This is an extra field that's not on the model, ignore it
46+                continue
47+            if name in self.cleaned_data and f.unique:
48+                unique_checks.append((name,))
49+        for unique_check in [check for check in unique_checks if not any([x in self._errors for x in check])]:
50+            kwargs = dict([(field_name, self.cleaned_data[field_name]) for field_name in unique_check])
51+            qs = self.instance.__class__._default_manager.filter(**kwargs)
52+            if self.instance.pk is not None:
53+                qs = qs.exclude(pk=self.instance.pk)
54+            if qs.extra(select={'a': 1}).values('a').order_by():
55+                model_name = self.instance._meta.verbose_name.title()
56+                if len(unique_check) == 1:
57+                    field_name = unique_check[0]
58+                    field_label = self.fields[field_name].label
59+                    self._errors[field_name] = ErrorList([
60+                        _("%(model_name)s with this %(field_label)s already exists.") % \
61+                        {'model_name': model_name, 'field_label': field_label}
62+                    ])
63+                else:
64+                    field_labels = [self.fields[field_name].label for field_name in unique_check]
65+                    field_labels = get_text_list(field_labels, _('and'))
66+                    form_errors.append(
67+                        _("%(model_name)s with this %(field_label)s already exists.") % \
68+                        {'model_name': model_name, 'field_label': field_labels}
69+                    )
70+                for field_name in unique_check:
71+                    del self.cleaned_data[field_name]
72+        if form_errors:
73+            raise ValidationError(form_errors)
74 
75     def save(self, commit=True):
76         """
77@@ -246,19 +293,27 @@ class BaseModelFormSet(BaseFormSet):
78                  queryset=None, **kwargs):
79         self.queryset = queryset
80         defaults = {'data': data, 'files': files, 'auto_id': auto_id, 'prefix': prefix}
81-        if self.max_num > 0:
82-            qs = self.get_queryset()[:self.max_num]
83-        else:
84-            qs = self.get_queryset()
85-        defaults['initial'] = [model_to_dict(obj) for obj in qs]
86+        defaults['initial'] = [model_to_dict(obj) for obj in self.get_queryset()]
87         defaults.update(kwargs)
88         super(BaseModelFormSet, self).__init__(**defaults)
89-
90+   
91+    def _construct_form(self, i, **kwargs):
92+        if i < self._initial_form_count:
93+            kwargs['instance'] = self.get_queryset()[i]
94+        return super(BaseModelFormSet, self)._construct_form(i, **kwargs)
95+       
96     def get_queryset(self):
97-        if self.queryset is not None:
98-            return self.queryset
99-        return self.model._default_manager.get_query_set()
100-
101+        if not hasattr(self, '_queryset'):
102+            if self.queryset is not None:
103+                qs = self.queryset
104+            else:
105+                qs = self.model._default_manager.get_query_set()
106+            if self.max_num > 0:
107+                self._queryset = qs[:self.max_num]
108+            else:
109+                self._queryset = qs
110+        return self._queryset
111+   
112     def save_new(self, form, commit=True):
113         """Saves and returns a new model instance for the given form."""
114         return save_instance(form, self.model(), exclude=[self._pk_field.name], commit=commit)
115@@ -358,6 +413,14 @@ class BaseInlineFormSet(BaseModelFormSet):
116             self._total_form_count = self._initial_form_count
117             self._initial_form_count = 0
118         super(BaseInlineFormSet, self)._construct_forms()
119+   
120+    def _construct_form(self, i, **kwargs):
121+        form = super(BaseInlineFormSet, self)._construct_form(i, **kwargs)
122+        if self.save_as_new:
123+            # kill the pk from form data so that it doesn't cause a unique
124+            # validation error when trying to re-save the forms.
125+            form.data[form.add_prefix(self._pk_field.name)] = None
126+        return form
127 
128     def get_queryset(self):
129         """
130diff --git a/django/utils/itercompat.py b/django/utils/itercompat.py
131index c166da3..1b24cc9 100644
132--- a/django/utils/itercompat.py
133+++ b/django/utils/itercompat.py
134@@ -72,3 +72,10 @@ def sorted(in_value):
135     out_value = in_value[:]
136     out_value.sort()
137     return out_value
138+
139+def any(seq):
140+    """any implementation."""
141+    for x in seq:
142+        if seq:
143+            return True
144+    return False
145diff --git a/docs/topics/forms/modelforms.txt b/docs/topics/forms/modelforms.txt
146index d161b3f..163c428 100644
147--- a/docs/topics/forms/modelforms.txt
148+++ b/docs/topics/forms/modelforms.txt
149@@ -338,6 +338,16 @@ parameter when declaring the form field::
150    ...     class Meta:
151    ...         model = Article
152 
153+Overriding the clean() method
154+-----------------------------
155+
156+You can overide the ``clean()`` method on a model form to provide additional
157+validation in the same way you can on a normal form.  However, by default the
158+``clean()`` method validates the uniqueness of fields that are marked as unique
159+on the model, and those marked as unque_together, if you would like to overide
160+the ``clean()`` method and maintain the default validation you must call the
161+parent class's ``clean()`` method.
162+
163 Form inheritance
164 ----------------
165 
166@@ -500,4 +510,4 @@ books of a specific author. Here is how you could accomplish this::
167     >>> from django.forms.models import inlineformset_factory
168     >>> BookFormSet = inlineformset_factory(Author, Book)
169     >>> author = Author.objects.get(name=u'Orson Scott Card')
170-    >>> formset = BookFormSet(instance=author)
171\ No newline at end of file
172+    >>> formset = BookFormSet(instance=author)
173diff --git a/tests/modeltests/model_forms/models.py b/tests/modeltests/model_forms/models.py
174index 5f714fb..9a306aa 100644
175--- a/tests/modeltests/model_forms/models.py
176+++ b/tests/modeltests/model_forms/models.py
177@@ -117,9 +117,26 @@ class CommaSeparatedInteger(models.Model):
178     def __unicode__(self):
179         return self.field
180 
181+class Product(models.Model):
182+    slug = models.SlugField(unique=True)
183+   
184+    def __unicode__(self):
185+        return self.slug
186+
187+class Price(models.Model):
188+    price = models.DecimalField(max_digits=10, decimal_places=2)
189+    quantity = models.PositiveIntegerField()
190+   
191+    def __unicode__(self):
192+        return u"%s for %s" % (self.quantity, self.price)
193+   
194+    class Meta:
195+        unique_together = (('price', 'quantity'),)
196+
197 class ArticleStatus(models.Model):
198     status = models.CharField(max_length=2, choices=ARTICLE_STATUS_CHAR, blank=True, null=True)
199 
200+
201 __test__ = {'API_TESTS': """
202 >>> from django import forms
203 >>> from django.forms.models import ModelForm, model_to_dict
204@@ -1132,8 +1149,42 @@ u'1,,2'
205 >>> f.clean('1')
206 u'1'
207 
208-# Choices on CharField and IntegerField
209+# unique/unique_together validation
210 
211+>>> class ProductForm(ModelForm):
212+...     class Meta:
213+...         model = Product
214+>>> form = ProductForm({'slug': 'teddy-bear-blue'})
215+>>> form.is_valid()
216+True
217+>>> obj = form.save()
218+>>> obj
219+<Product: teddy-bear-blue>
220+>>> form = ProductForm({'slug': 'teddy-bear-blue'})
221+>>> form.is_valid()
222+False
223+>>> form._errors
224+{'slug': [u'Product with this Slug already exists.']}
225+>>> form = ProductForm({'slug': 'teddy-bear-blue'}, instance=obj)
226+>>> form.is_valid()
227+True
228+
229+# ModelForm test of unique_together constraint
230+>>> class PriceForm(ModelForm):
231+...     class Meta:
232+...         model = Price
233+>>> form = PriceForm({'price': '6.00', 'quantity': '1'})
234+>>> form.is_valid()
235+True
236+>>> form.save()
237+<Price: 1 for 6.00>
238+>>> form = PriceForm({'price': '6.00', 'quantity': '1'})
239+>>> form.is_valid()
240+False
241+>>> form._errors
242+{'__all__': [u'Price with this Price and Quantity already exists.']}
243+
244+# Choices on CharField and IntegerField
245 >>> class ArticleForm(ModelForm):
246 ...     class Meta:
247 ...         model = Article
248diff --git a/tests/modeltests/model_formsets/models.py b/tests/modeltests/model_formsets/models.py
249index 332c5a7..1e25baa 100644
250--- a/tests/modeltests/model_formsets/models.py
251+++ b/tests/modeltests/model_formsets/models.py
252@@ -73,6 +73,22 @@ class Restaurant(Place):
253     def __unicode__(self):
254         return self.name
255 
256+class Product(models.Model):
257+    slug = models.SlugField(unique=True)
258+
259+    def __unicode__(self):
260+        return self.slug
261+
262+class Price(models.Model):
263+    price = models.DecimalField(max_digits=10, decimal_places=2)
264+    quantity = models.PositiveIntegerField()
265+
266+    def __unicode__(self):
267+        return u"%s for %s" % (self.quantity, self.price)
268+
269+    class Meta:
270+        unique_together = (('price', 'quantity'),)
271+
272 class MexicanRestaurant(Restaurant):
273     serves_tacos = models.BooleanField()
274 
275@@ -553,4 +569,56 @@ True
276 >>> type(_get_foreign_key(MexicanRestaurant, Owner))
277 <class 'django.db.models.fields.related.ForeignKey'>
278 
279+# unique/unique_together validation ###########################################
280+
281+>>> FormSet = modelformset_factory(Product, extra=1)
282+>>> data = {
283+...     'form-TOTAL_FORMS': '1',
284+...     'form-INITIAL_FORMS': '0',
285+...     'form-0-slug': 'car-red',
286+... }
287+>>> formset = FormSet(data)
288+>>> formset.is_valid()
289+True
290+>>> formset.save()
291+[<Product: car-red>]
292+
293+>>> data = {
294+...     'form-TOTAL_FORMS': '1',
295+...     'form-INITIAL_FORMS': '0',
296+...     'form-0-slug': 'car-red',
297+... }
298+>>> formset = FormSet(data)
299+>>> formset.is_valid()
300+False
301+>>> formset.errors
302+[{'slug': [u'Product with this Slug already exists.']}]
303+
304+# unique_together
305+
306+>>> FormSet = modelformset_factory(Price, extra=1)
307+>>> data = {
308+...     'form-TOTAL_FORMS': '1',
309+...     'form-INITIAL_FORMS': '0',
310+...     'form-0-price': u'12.00',
311+...     'form-0-quantity': '1',
312+... }
313+>>> formset = FormSet(data)
314+>>> formset.is_valid()
315+True
316+>>> formset.save()
317+[<Price: 1 for 12.00>]
318+
319+>>> data = {
320+...     'form-TOTAL_FORMS': '1',
321+...     'form-INITIAL_FORMS': '0',
322+...     'form-0-price': u'12.00',
323+...     'form-0-quantity': '1',
324+... }
325+>>> formset = FormSet(data)
326+>>> formset.is_valid()
327+False
328+>>> formset.errors
329+[{'__all__': [u'Price with this Price and Quantity already exists.']}]
330+
331 """}