diff --git a/django/db/models/base.py b/django/db/models/base.py index 51707b82ced..27d571ca13b 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -707,37 +707,50 @@ class Model(object): if exclude is None: exclude = [] unique_checks = [] - for check in self._meta.unique_together: - for name in check: - # If this is an excluded field, don't add this check. - if name in exclude: - break - else: - unique_checks.append(tuple(check)) + + unique_togethers = [(self.__class__, self._meta.unique_together)] + for parent_class in self._meta.parents.keys(): + if parent_class._meta.unique_together: + unique_togethers.append((parent_class, parent_class._meta.unique_together)) + + for model_class, unique_together in unique_togethers: + for check in unique_together: + for name in check: + # If this is an excluded field, don't add this check. + if name in exclude: + break + else: + unique_checks.append((model_class, tuple(check))) # These are checks for the unique_for_. date_checks = [] # Gather a list of checks for fields declared as unique and add them to # the list of checks. - for f in self._meta.fields: - name = f.name - if name in exclude: - continue - if f.unique: - unique_checks.append((name,)) - if f.unique_for_date: - date_checks.append(('date', name, f.unique_for_date)) - if f.unique_for_year: - date_checks.append(('year', name, f.unique_for_year)) - if f.unique_for_month: - date_checks.append(('month', name, f.unique_for_month)) + + fields_with_class = [(self.__class__, self._meta.local_fields)] + for parent_class in self._meta.parents.keys(): + fields_with_class.append((parent_class, parent_class._meta.local_fields)) + + for model_class, fields in fields_with_class: + for f in fields: + name = f.name + if name in exclude: + continue + if f.unique: + unique_checks.append((model_class, (name,))) + if f.unique_for_date: + date_checks.append((model_class, 'date', name, f.unique_for_date)) + if f.unique_for_year: + date_checks.append((model_class, 'year', name, f.unique_for_year)) + if f.unique_for_month: + date_checks.append((model_class, 'month', name, f.unique_for_month)) return unique_checks, date_checks def _perform_unique_checks(self, unique_checks): errors = {} - for unique_check in unique_checks: + for model_class, unique_check in unique_checks: # Try to look up an existing object with the same values as this # object's values for all the unique field. @@ -757,7 +770,7 @@ class Model(object): if len(unique_check) != len(lookup_kwargs.keys()): continue - qs = self.__class__._default_manager.filter(**lookup_kwargs) + qs = model_class._default_manager.filter(**lookup_kwargs) # Exclude the current object from the query if we are editing an # instance (as opposed to creating a new one) @@ -769,13 +782,13 @@ class Model(object): key = unique_check[0] else: key = NON_FIELD_ERRORS - errors.setdefault(key, []).append(self.unique_error_message(unique_check)) + errors.setdefault(key, []).append(self.unique_error_message(model_class, unique_check)) return errors def _perform_date_checks(self, date_checks): errors = {} - for lookup_type, field, unique_for in date_checks: + for model_class, lookup_type, field, unique_for in date_checks: lookup_kwargs = {} # there's a ticket to add a date lookup, we can remove this special # case if that makes it's way in @@ -788,7 +801,7 @@ class Model(object): lookup_kwargs['%s__%s' % (unique_for, lookup_type)] = getattr(date, lookup_type) lookup_kwargs[field] = getattr(self, field) - qs = self.__class__._default_manager.filter(**lookup_kwargs) + qs = model_class._default_manager.filter(**lookup_kwargs) # Exclude the current object from the query if we are editing an # instance (as opposed to creating a new one) if not getattr(self, '_adding', False) and self.pk is not None: @@ -808,8 +821,8 @@ class Model(object): 'lookup': lookup_type, } - def unique_error_message(self, unique_check): - opts = self._meta + def unique_error_message(self, model_class, unique_check): + opts = model_class._meta model_name = capfirst(opts.verbose_name) # A unique field diff --git a/django/forms/models.py b/django/forms/models.py index 45a2e0cd161..13aea4acab1 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -488,7 +488,7 @@ class BaseModelFormSet(BaseFormSet): errors = [] # Do each of the unique checks (unique and unique_together) - for unique_check in all_unique_checks: + for uclass, unique_check in all_unique_checks: seen_data = set() for form in self.forms: # if the form doesn't have cleaned_data then we ignore it, @@ -512,7 +512,7 @@ class BaseModelFormSet(BaseFormSet): # iterate over each of the date checks now for date_check in all_date_checks: seen_data = set() - lookup, field, unique_for = date_check + uclass, 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 @@ -556,9 +556,9 @@ class BaseModelFormSet(BaseFormSet): 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]), + 'field_name': date_check[2], + 'date_field': date_check[3], + 'lookup': unicode(date_check[1]), } def get_form_error(self): diff --git a/tests/modeltests/model_forms/mforms.py b/tests/modeltests/model_forms/mforms.py new file mode 100755 index 00000000000..5777b4cb6e4 --- /dev/null +++ b/tests/modeltests/model_forms/mforms.py @@ -0,0 +1,32 @@ +from django.forms import ModelForm + +from models import Product, Price, Book, DerivedBook, ExplicitPK, Post, DerivedPost + +class ProductForm(ModelForm): + class Meta: + model = Product + +class PriceForm(ModelForm): + class Meta: + model = Price + +class BookForm(ModelForm): + class Meta: + model = Book + +class DerivedBookForm(ModelForm): + class Meta: + model = DerivedBook + +class ExplicitPKForm(ModelForm): + class Meta: + model = ExplicitPK + fields = ('key', 'desc',) + +class PostForm(ModelForm): + class Meta: + model = Post + +class DerivedPostForm(ModelForm): + class Meta: + model = DerivedPost diff --git a/tests/modeltests/model_forms/models.py b/tests/modeltests/model_forms/models.py index b8b826ac091..998f212fae8 100644 --- a/tests/modeltests/model_forms/models.py +++ b/tests/modeltests/model_forms/models.py @@ -101,7 +101,7 @@ try: from PIL import Image, _imaging except ImportError: import Image, _imaging - + test_images = True class ImageFile(models.Model): @@ -181,6 +181,18 @@ class Book(models.Model): class Meta: unique_together = ('title', 'author') +class BookXtra(models.Model): + isbn = models.CharField(max_length=16, unique=True) + suffix1 = models.IntegerField(blank=True, default=0) + suffix2 = models.IntegerField(blank=True, default=0) + + class Meta: + unique_together = (('suffix1', 'suffix2')) + abstract = True + +class DerivedBook(Book, BookXtra): + pass + class ExplicitPK(models.Model): key = models.CharField(max_length=20, primary_key=True) desc = models.CharField(max_length=20, blank=True, unique=True) @@ -199,6 +211,9 @@ class Post(models.Model): def __unicode__(self): return self.name +class DerivedPost(Post): + pass + class BigInt(models.Model): biggie = models.BigIntegerField() @@ -1424,41 +1439,6 @@ True >>> f.cleaned_data {'field': u'1'} -# unique/unique_together validation - ->>> class ProductForm(ModelForm): -... class Meta: -... model = Product ->>> form = ProductForm({'slug': 'teddy-bear-blue'}) ->>> form.is_valid() -True ->>> obj = form.save() ->>> obj - ->>> form = ProductForm({'slug': 'teddy-bear-blue'}) ->>> form.is_valid() -False ->>> form._errors -{'slug': [u'Product with this Slug already exists.']} ->>> form = ProductForm({'slug': 'teddy-bear-blue'}, instance=obj) ->>> form.is_valid() -True - -# ModelForm test of unique_together constraint ->>> class PriceForm(ModelForm): -... class Meta: -... model = Price ->>> form = PriceForm({'price': '6.00', 'quantity': '1'}) ->>> form.is_valid() -True ->>> form.save() - ->>> form = PriceForm({'price': '6.00', 'quantity': '1'}) ->>> form.is_valid() -False ->>> form._errors -{'__all__': [u'Price with this Price and Quantity already exists.']} - This Price instance generated by this form is not valid because the quantity field is required, but the form is valid because the field is excluded from the form. This is for backwards compatibility. @@ -1495,51 +1475,6 @@ True >>> form.instance.pk is None True -# Unique & unique together with null values ->>> class BookForm(ModelForm): -... class Meta: -... model = Book ->>> w = Writer.objects.get(name='Mike Royko') ->>> form = BookForm({'title': 'I May Be Wrong But I Doubt It', 'author' : w.pk}) ->>> form.is_valid() -True ->>> form.save() - ->>> form = BookForm({'title': 'I May Be Wrong But I Doubt It', 'author' : w.pk}) ->>> form.is_valid() -False ->>> form._errors -{'__all__': [u'Book with this Title and Author already exists.']} ->>> form = BookForm({'title': 'I May Be Wrong But I Doubt It'}) ->>> form.is_valid() -True ->>> form.save() - ->>> form = BookForm({'title': 'I May Be Wrong But I Doubt It'}) ->>> form.is_valid() -True - -# Test for primary_key being in the form and failing validation. ->>> class ExplicitPKForm(ModelForm): -... class Meta: -... model = ExplicitPK -... fields = ('key', 'desc',) ->>> form = ExplicitPKForm({'key': u'', 'desc': u'' }) ->>> form.is_valid() -False - -# Ensure keys and blank character strings are tested for uniqueness. ->>> form = ExplicitPKForm({'key': u'key1', 'desc': u''}) ->>> form.is_valid() -True ->>> form.save() - ->>> form = ExplicitPKForm({'key': u'key1', 'desc': u''}) ->>> form.is_valid() -False ->>> sorted(form.errors.items()) -[('__all__', [u'Explicit pk with this Key and Desc already exists.']), ('desc', [u'Explicit pk with this Desc already exists.']), ('key', [u'Explicit pk with this Key already exists.'])] - # Choices on CharField and IntegerField >>> class ArticleForm(ModelForm): ... class Meta: @@ -1605,38 +1540,6 @@ ValidationError: [u'Select a valid choice. z is not one of the available choices -### Validation on unique_for_date - ->>> p = Post.objects.create(title="Django 1.0 is released", slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3)) ->>> class PostForm(ModelForm): -... class Meta: -... model = Post - ->>> f = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'}) ->>> f.is_valid() -False ->>> f.errors -{'title': [u'Title must be unique for Posted date.']} ->>> f = PostForm({'title': "Work on Django 1.1 begins", 'posted': '2008-09-03'}) ->>> f.is_valid() -True ->>> f = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-04'}) ->>> f.is_valid() -True ->>> f = PostForm({'slug': "Django 1.0", 'posted': '2008-01-01'}) ->>> f.is_valid() -False ->>> f.errors -{'slug': [u'Slug must be unique for Posted year.']} ->>> f = PostForm({'subtitle': "Finally", 'posted': '2008-09-30'}) ->>> f.is_valid() -False ->>> f.errors -{'subtitle': [u'Subtitle must be unique for Posted month.']} ->>> f = PostForm({'subtitle': "Finally", "title": "Django 1.0 is released", "slug": "Django 1.0", 'posted': '2008-09-03'}, instance=p) ->>> f.is_valid() -True - # Clean up >>> import shutil >>> shutil.rmtree(temp_storage_dir) diff --git a/tests/modeltests/model_forms/tests.py b/tests/modeltests/model_forms/tests.py index 53060105c7e..fb4e8adb451 100644 --- a/tests/modeltests/model_forms/tests.py +++ b/tests/modeltests/model_forms/tests.py @@ -1,6 +1,8 @@ +import datetime from django.test import TestCase from django import forms -from models import Category +from models import Category, Writer, Book, DerivedBook, Post +from mforms import ProductForm, PriceForm, BookForm, DerivedBookForm, ExplicitPKForm, PostForm, DerivedPostForm class IncompleteCategoryFormWithFields(forms.ModelForm): @@ -35,3 +37,140 @@ class ValidationTest(TestCase): form = IncompleteCategoryFormWithExclude(data={'name': 'some name', 'slug': 'some-slug'}) assert form.is_valid() +# unique/unique_together validation +class UniqueTest(TestCase): + def setUp(self): + self.writer = Writer.objects.create(name='Mike Royko') + + def test_simple_unique(self): + form = ProductForm({'slug': 'teddy-bear-blue'}) + self.assertTrue(form.is_valid()) + obj = form.save() + form = ProductForm({'slug': 'teddy-bear-blue'}) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['slug'], [u'Product with this Slug already exists.']) + form = ProductForm({'slug': 'teddy-bear-blue'}, instance=obj) + self.assertTrue(form.is_valid()) + + def test_unique_together(self): + """ModelForm test of unique_together constraint""" + form = PriceForm({'price': '6.00', 'quantity': '1'}) + self.assertTrue(form.is_valid()) + form.save() + form = PriceForm({'price': '6.00', 'quantity': '1'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['__all__'], [u'Price with this Price and Quantity already exists.']) + + def test_unique_null(self): + title = 'I May Be Wrong But I Doubt It' + form = BookForm({'title': title, 'author': self.writer.pk}) + self.assertTrue(form.is_valid()) + form.save() + form = BookForm({'title': title, 'author': self.writer.pk}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['__all__'], [u'Book with this Title and Author already exists.']) + form = BookForm({'title': title}) + self.assertTrue(form.is_valid()) + form.save() + form = BookForm({'title': title}) + self.assertTrue(form.is_valid()) + + def test_inherited_unique(self): + title = 'Boss' + Book.objects.create(title=title, author=self.writer, special_id=1) + form = DerivedBookForm({'title': 'Other', 'author': self.writer.pk, 'special_id': u'1', 'isbn': '12345'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['special_id'], [u'Book with this Special id already exists.']) + + def test_inherited_unique_together(self): + title = 'Boss' + form = BookForm({'title': title, 'author': self.writer.pk}) + self.assertTrue(form.is_valid()) + form.save() + form = DerivedBookForm({'title': title, 'author': self.writer.pk, 'isbn': '12345'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['__all__'], [u'Book with this Title and Author already exists.']) + + def test_abstract_inherited_unique(self): + title = 'Boss' + isbn = '12345' + dbook = DerivedBook.objects.create(title=title, author=self.writer, isbn=isbn) + form = DerivedBookForm({'title': 'Other', 'author': self.writer.pk, 'isbn': isbn}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['isbn'], [u'Derived book with this Isbn already exists.']) + + def test_abstract_inherited_unique_together(self): + title = 'Boss' + isbn = '12345' + dbook = DerivedBook.objects.create(title=title, author=self.writer, isbn=isbn) + form = DerivedBookForm({'title': 'Other', 'author': self.writer.pk, 'isbn': '9876', 'suffix1': u'0', 'suffix2': u'0'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['__all__'], [u'Derived book with this Suffix1 and Suffix2 already exists.']) + + def test_explicitpk_unspecified(self): + """Test for primary_key being in the form and failing validation.""" + form = ExplicitPKForm({'key': u'', 'desc': u'' }) + self.assertFalse(form.is_valid()) + + def test_explicitpk_unique(self): + """Ensure keys and blank character strings are tested for uniqueness.""" + form = ExplicitPKForm({'key': u'key1', 'desc': u''}) + self.assertTrue(form.is_valid()) + form.save() + form = ExplicitPKForm({'key': u'key1', 'desc': u''}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 3) + self.assertEqual(form.errors['__all__'], [u'Explicit pk with this Key and Desc already exists.']) + self.assertEqual(form.errors['desc'], [u'Explicit pk with this Desc already exists.']) + self.assertEqual(form.errors['key'], [u'Explicit pk with this Key already exists.']) + + def test_unique_for_date(self): + p = Post.objects.create(title="Django 1.0 is released", + slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3)) + form = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['title'], [u'Title must be unique for Posted date.']) + form = PostForm({'title': "Work on Django 1.1 begins", 'posted': '2008-09-03'}) + self.assertTrue(form.is_valid()) + form = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-04'}) + self.assertTrue(form.is_valid()) + form = PostForm({'slug': "Django 1.0", 'posted': '2008-01-01'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['slug'], [u'Slug must be unique for Posted year.']) + form = PostForm({'subtitle': "Finally", 'posted': '2008-09-30'}) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors['subtitle'], [u'Subtitle must be unique for Posted month.']) + form = PostForm({'subtitle': "Finally", "title": "Django 1.0 is released", + "slug": "Django 1.0", 'posted': '2008-09-03'}, instance=p) + self.assertTrue(form.is_valid()) + + def test_inherited_unique_for_date(self): + p = Post.objects.create(title="Django 1.0 is released", + slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3)) + form = DerivedPostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['title'], [u'Title must be unique for Posted date.']) + form = DerivedPostForm({'title': "Work on Django 1.1 begins", 'posted': '2008-09-03'}) + self.assertTrue(form.is_valid()) + form = DerivedPostForm({'title': "Django 1.0 is released", 'posted': '2008-09-04'}) + self.assertTrue(form.is_valid()) + form = DerivedPostForm({'slug': "Django 1.0", 'posted': '2008-01-01'}) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertEqual(form.errors['slug'], [u'Slug must be unique for Posted year.']) + form = DerivedPostForm({'subtitle': "Finally", 'posted': '2008-09-30'}) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors['subtitle'], [u'Subtitle must be unique for Posted month.']) + form = DerivedPostForm({'subtitle': "Finally", "title": "Django 1.0 is released", + "slug": "Django 1.0", 'posted': '2008-09-03'}, instance=p) + self.assertTrue(form.is_valid()) + diff --git a/tests/modeltests/validation/test_unique.py b/tests/modeltests/validation/test_unique.py index ebda9abe77f..1b966390c44 100644 --- a/tests/modeltests/validation/test_unique.py +++ b/tests/modeltests/validation/test_unique.py @@ -9,26 +9,34 @@ class GetUniqueCheckTests(unittest.TestCase): def test_unique_fields_get_collected(self): m = UniqueFieldsModel() self.assertEqual( - ([('id',), ('unique_charfield',), ('unique_integerfield',)], []), + ([(UniqueFieldsModel, ('id',)), + (UniqueFieldsModel, ('unique_charfield',)), + (UniqueFieldsModel, ('unique_integerfield',))], + []), m._get_unique_checks() ) def test_unique_together_gets_picked_up_and_converted_to_tuple(self): m = UniqueTogetherModel() self.assertEqual( - ([('ifield', 'cfield',),('ifield', 'efield'), ('id',), ], []), + ([(UniqueTogetherModel, ('ifield', 'cfield',)), + (UniqueTogetherModel, ('ifield', 'efield')), + (UniqueTogetherModel, ('id',)), ], + []), m._get_unique_checks() ) def test_primary_key_is_considered_unique(self): m = CustomPKModel() - self.assertEqual(([('my_pk_field',)], []), m._get_unique_checks()) + self.assertEqual(([(CustomPKModel, ('my_pk_field',))], []), m._get_unique_checks()) def test_unique_for_date_gets_picked_up(self): m = UniqueForDateModel() self.assertEqual(( - [('id',)], - [('date', 'count', 'start_date'), ('year', 'count', 'end_date'), ('month', 'order', 'end_date')] + [(UniqueForDateModel, ('id',))], + [(UniqueForDateModel, 'date', 'count', 'start_date'), + (UniqueForDateModel, 'year', 'count', 'end_date'), + (UniqueForDateModel, 'month', 'order', 'end_date')] ), m._get_unique_checks() )