diff --git a/tests/forms_tests/models.py b/tests/forms_tests/models.py index fa63d08fd1..327fd7802c 100644 --- a/tests/forms_tests/models.py +++ b/tests/forms_tests/models.py @@ -130,12 +130,5 @@ class FileModel(models.Model): file = models.FileField(storage=temp_storage, upload_to='tests') -class Group(models.Model): - name = models.CharField(max_length=10) - - def __str__(self): - return '%s' % self.name - - class Article(models.Model): content = models.TextField() diff --git a/tests/forms_tests/tests/tests.py b/tests/forms_tests/tests/tests.py index 841a1c6799..de04e8a3bc 100644 --- a/tests/forms_tests/tests/tests.py +++ b/tests/forms_tests/tests/tests.py @@ -2,15 +2,13 @@ import datetime from django.core.files.uploadedfile import SimpleUploadedFile from django.db import models -from django.forms import ( - CharField, FileField, Form, ModelChoiceField, ModelForm, -) +from django.forms import CharField, FileField, Form, ModelForm from django.forms.models import ModelFormMetaclass from django.test import SimpleTestCase, TestCase from ..models import ( BoundaryModel, ChoiceFieldModel, ChoiceModel, ChoiceOptionModel, Defaults, - FileModel, Group, OptionalMultiChoiceModel, + FileModel, OptionalMultiChoiceModel, ) @@ -56,24 +54,6 @@ class FileForm(Form): file1 = FileField() -class TestModelChoiceField(TestCase): - - def test_choices_not_fetched_when_not_rendering(self): - """ - Generating choices for ModelChoiceField should require 1 query (#12510). - """ - self.groups = [Group.objects.create(name=name) for name in 'abc'] - # only one query is required to pull the model from DB - with self.assertNumQueries(1): - field = ModelChoiceField(Group.objects.order_by('-name')) - self.assertEqual('a', field.clean(self.groups[0].pk).name) - - def test_queryset_manager(self): - f = ModelChoiceField(ChoiceOptionModel.objects) - choice = ChoiceOptionModel.objects.create(name="choice 1") - self.assertEqual(list(f.choices), [('', '---------'), (choice.pk, str(choice))]) - - class TestTicket14567(TestCase): """ The return values of ModelMultipleChoiceFields are QuerySets diff --git a/tests/model_forms/test_modelchoicefield.py b/tests/model_forms/test_modelchoicefield.py new file mode 100644 index 0000000000..057eb0d25b --- /dev/null +++ b/tests/model_forms/test_modelchoicefield.py @@ -0,0 +1,275 @@ +import datetime + +from django import forms +from django.core.validators import ValidationError +from django.forms.models import ModelChoiceIterator +from django.forms.widgets import CheckboxSelectMultiple +from django.template import Context, Template +from django.test import TestCase + +from .models import Article, Author, Book, Category, Writer + + +class ModelChoiceFieldTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.c1 = Category.objects.create(name='Entertainment', slug='entertainment', url='entertainment') + cls.c2 = Category.objects.create(name='A test', slug='test', url='test') + cls.c3 = Category.objects.create(name='Third', slug='third-test', url='third') + + def test_basics(self): + f = forms.ModelChoiceField(Category.objects.all()) + self.assertEqual(list(f.choices), [ + ('', '---------'), + (self.c1.pk, 'Entertainment'), + (self.c2.pk, 'A test'), + (self.c3.pk, 'Third'), + ]) + with self.assertRaises(ValidationError): + f.clean('') + with self.assertRaises(ValidationError): + f.clean(None) + with self.assertRaises(ValidationError): + f.clean(0) + + # Invalid types that require TypeError to be caught. + with self.assertRaises(ValidationError): + f.clean([['fail']]) + with self.assertRaises(ValidationError): + f.clean([{'foo': 'bar'}]) + + self.assertEqual(f.clean(self.c2.id).name, 'A test') + self.assertEqual(f.clean(self.c3.id).name, 'Third') + + # Add a Category object *after* the ModelChoiceField has already been + # instantiated. This proves clean() checks the database during clean() + # rather than caching it at instantiation time. + c4 = Category.objects.create(name='Fourth', url='4th') + self.assertEqual(f.clean(c4.id).name, 'Fourth') + + # Delete a Category object *after* the ModelChoiceField has already been + # instantiated. This proves clean() checks the database during clean() + # rather than caching it at instantiation time. + Category.objects.get(url='4th').delete() + msg = "['Select a valid choice. That choice is not one of the available choices.']" + with self.assertRaisesMessage(ValidationError, msg): + f.clean(c4.id) + + def test_choices(self): + f = forms.ModelChoiceField(Category.objects.filter(pk=self.c1.id), required=False) + self.assertIsNone(f.clean('')) + self.assertEqual(f.clean(str(self.c1.id)).name, 'Entertainment') + with self.assertRaises(ValidationError): + f.clean('100') + + # len() can be called on choices. + self.assertEqual(len(f.choices), 2) + + # queryset can be changed after the field is created. + f.queryset = Category.objects.exclude(name='Third') + self.assertEqual(list(f.choices), [ + ('', '---------'), + (self.c1.pk, 'Entertainment'), + (self.c2.pk, 'A test'), + ]) + self.assertEqual(f.clean(self.c2.id).name, 'A test') + with self.assertRaises(ValidationError): + f.clean(self.c3.id) + + # Choices can be iterated repeatedly. + gen_one = list(f.choices) + gen_two = f.choices + self.assertEqual(gen_one[2], (self.c2.pk, 'A test')) + self.assertEqual(list(gen_two), [ + ('', '---------'), + (self.c1.pk, 'Entertainment'), + (self.c2.pk, 'A test'), + ]) + + # Overriding label_from_instance() to print custom labels. + f.queryset = Category.objects.all() + f.label_from_instance = lambda obj: 'category ' + str(obj) + self.assertEqual(list(f.choices), [ + ('', '---------'), + (self.c1.pk, 'category Entertainment'), + (self.c2.pk, 'category A test'), + (self.c3.pk, 'category Third'), + ]) + + def test_deepcopies_widget(self): + class ModelChoiceForm(forms.Form): + category = forms.ModelChoiceField(Category.objects.all()) + + form1 = ModelChoiceForm() + field1 = form1.fields['category'] + # To allow the widget to change the queryset of field1.widget.choices + # without affecting other forms, the following must hold (#11183): + self.assertIsNot(field1, ModelChoiceForm.base_fields['category']) + self.assertIs(field1.widget.choices.field, field1) + + def test_result_cache_not_shared(self): + class ModelChoiceForm(forms.Form): + category = forms.ModelChoiceField(Category.objects.all()) + + form1 = ModelChoiceForm() + self.assertCountEqual(form1.fields['category'].queryset, [self.c1, self.c2, self.c3]) + form2 = ModelChoiceForm() + self.assertIsNone(form2.fields['category'].queryset._result_cache) + + def test_queryset_none(self): + class ModelChoiceForm(forms.Form): + category = forms.ModelChoiceField(queryset=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['category'].queryset = Category.objects.filter(slug__contains='test') + + form = ModelChoiceForm() + self.assertCountEqual(form.fields['category'].queryset, [self.c2, self.c3]) + + def test_no_extra_query_when_accessing_attrs(self): + """ + ModelChoiceField with RadioSelect widget doesn't produce unnecessary + db queries when accessing its BoundField's attrs. + """ + class ModelChoiceForm(forms.Form): + category = forms.ModelChoiceField(Category.objects.all(), widget=forms.RadioSelect) + + form = ModelChoiceForm() + field = form['category'] # BoundField + template = Template('{{ field.name }}{{ field }}{{ field.help_text }}') + with self.assertNumQueries(1): + template.render(Context({'field': field})) + + def test_disabled_modelchoicefield(self): + class ModelChoiceForm(forms.ModelForm): + author = forms.ModelChoiceField(Author.objects.all(), disabled=True) + + class Meta: + model = Book + fields = ['author'] + + book = Book.objects.create(author=Writer.objects.create(name='Test writer')) + form = ModelChoiceForm({}, instance=book) + self.assertEqual( + form.errors['author'], + ['Select a valid choice. That choice is not one of the available choices.'] + ) + + def test_disabled_modelchoicefield_has_changed(self): + field = forms.ModelChoiceField(Author.objects.all(), disabled=True) + self.assertIs(field.has_changed('x', 'y'), False) + + def test_disabled_multiplemodelchoicefield(self): + class ArticleForm(forms.ModelForm): + categories = forms.ModelMultipleChoiceField(Category.objects.all(), required=False) + + class Meta: + model = Article + fields = ['categories'] + + category1 = Category.objects.create(name='cat1') + category2 = Category.objects.create(name='cat2') + article = Article.objects.create( + pub_date=datetime.date(1988, 1, 4), + writer=Writer.objects.create(name='Test writer'), + ) + article.categories.set([category1.pk]) + + form = ArticleForm(data={'categories': [category2.pk]}, instance=article) + self.assertEqual(form.errors, {}) + self.assertEqual([x.pk for x in form.cleaned_data['categories']], [category2.pk]) + # Disabled fields use the value from `instance` rather than `data`. + form = ArticleForm(data={'categories': [category2.pk]}, instance=article) + form.fields['categories'].disabled = True + self.assertEqual(form.errors, {}) + self.assertEqual([x.pk for x in form.cleaned_data['categories']], [category1.pk]) + + def test_disabled_modelmultiplechoicefield_has_changed(self): + field = forms.ModelMultipleChoiceField(Author.objects.all(), disabled=True) + self.assertIs(field.has_changed('x', 'y'), False) + + def test_overridable_choice_iterator(self): + """ + Iterator defaults to ModelChoiceIterator and can be overridden with + the iterator attribute on a ModelChoiceField subclass. + """ + field = forms.ModelChoiceField(Category.objects.all()) + self.assertIsInstance(field.choices, ModelChoiceIterator) + + class CustomModelChoiceIterator(ModelChoiceIterator): + pass + + class CustomModelChoiceField(forms.ModelChoiceField): + iterator = CustomModelChoiceIterator + + field = CustomModelChoiceField(Category.objects.all()) + self.assertIsInstance(field.choices, CustomModelChoiceIterator) + + def test_choice_iterator_passes_model_to_widget(self): + class CustomModelChoiceValue: + def __init__(self, value, obj): + self.value = value + self.obj = obj + + def __str__(self): + return str(self.value) + + class CustomModelChoiceIterator(ModelChoiceIterator): + def choice(self, obj): + value, label = super().choice(obj) + return CustomModelChoiceValue(value, obj), label + + class CustomCheckboxSelectMultiple(CheckboxSelectMultiple): + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + option = super().create_option(name, value, label, selected, index, subindex=None, attrs=None) + # Modify the HTML based on the object being rendered. + c = value.obj + option['attrs']['data-slug'] = c.slug + return option + + class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField): + iterator = CustomModelChoiceIterator + widget = CustomCheckboxSelectMultiple + + field = CustomModelMultipleChoiceField(Category.objects.all()) + self.assertHTMLEqual( + field.widget.render('name', []), + '''''' % (self.c1.pk, self.c2.pk, self.c3.pk), + ) + + def test_choices_not_fetched_when_not_rendering(self): + with self.assertNumQueries(1): + field = forms.ModelChoiceField(Category.objects.order_by('-name')) + self.assertEqual('Entertainment', field.clean(self.c1.pk).name) + + def test_queryset_manager(self): + f = forms.ModelChoiceField(Category.objects) + self.assertEqual(list(f.choices), [ + ('', '---------'), + (self.c1.pk, 'Entertainment'), + (self.c2.pk, 'A test'), + (self.c3.pk, 'Third'), + ]) + + def test_num_queries(self): + """ + Widgets that render multiple subwidgets shouldn't make more than one + database query. + """ + categories = Category.objects.all() + + class CategoriesForm(forms.Form): + radio = forms.ModelChoiceField(queryset=categories, widget=forms.RadioSelect) + checkbox = forms.ModelMultipleChoiceField(queryset=categories, widget=forms.CheckboxSelectMultiple) + + template = Template( + '{% for widget in form.checkbox %}{{ widget }}{% endfor %}' + '{% for widget in form.radio %}{{ widget }}{% endfor %}' + ) + with self.assertNumQueries(2): + template.render(Context({'form': CategoriesForm()})) diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index 9b991d209d..fced7403ba 100644 --- a/tests/model_forms/tests.py +++ b/tests/model_forms/tests.py @@ -12,10 +12,9 @@ from django.core.validators import ValidationError from django.db import connection, models from django.db.models.query import EmptyQuerySet from django.forms.models import ( - ModelChoiceIterator, ModelFormMetaclass, construct_instance, - fields_for_model, model_to_dict, modelform_factory, + ModelFormMetaclass, construct_instance, fields_for_model, model_to_dict, + modelform_factory, ) -from django.forms.widgets import CheckboxSelectMultiple from django.template import Context, Template from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature @@ -1557,261 +1556,6 @@ class ModelFormBasicTests(TestCase): obj.full_clean() -class ModelChoiceFieldTests(TestCase): - def setUp(self): - self.c1 = Category.objects.create( - name="Entertainment", slug="entertainment", url="entertainment") - self.c2 = Category.objects.create( - name="It's a test", slug="its-test", url="test") - self.c3 = Category.objects.create( - name="Third", slug="third-test", url="third") - - # ModelChoiceField ############################################################ - def test_modelchoicefield(self): - f = forms.ModelChoiceField(Category.objects.all()) - self.assertEqual(list(f.choices), [ - ('', '---------'), - (self.c1.pk, 'Entertainment'), - (self.c2.pk, "It's a test"), - (self.c3.pk, 'Third')]) - with self.assertRaises(ValidationError): - f.clean('') - with self.assertRaises(ValidationError): - f.clean(None) - with self.assertRaises(ValidationError): - f.clean(0) - - # Invalid types that require TypeError to be caught (#22808). - with self.assertRaises(ValidationError): - f.clean([['fail']]) - with self.assertRaises(ValidationError): - f.clean([{'foo': 'bar'}]) - - self.assertEqual(f.clean(self.c2.id).name, "It's a test") - self.assertEqual(f.clean(self.c3.id).name, 'Third') - - # Add a Category object *after* the ModelChoiceField has already been - # instantiated. This proves clean() checks the database during clean() rather - # than caching it at time of instantiation. - c4 = Category.objects.create(name='Fourth', url='4th') - self.assertEqual(f.clean(c4.id).name, 'Fourth') - - # Delete a Category object *after* the ModelChoiceField has already been - # instantiated. This proves clean() checks the database during clean() rather - # than caching it at time of instantiation. - Category.objects.get(url='4th').delete() - msg = "['Select a valid choice. That choice is not one of the available choices.']" - with self.assertRaisesMessage(ValidationError, msg): - f.clean(c4.id) - - def test_modelchoicefield_choices(self): - f = forms.ModelChoiceField(Category.objects.filter(pk=self.c1.id), required=False) - self.assertIsNone(f.clean('')) - self.assertEqual(f.clean(str(self.c1.id)).name, "Entertainment") - with self.assertRaises(ValidationError): - f.clean('100') - - # len can be called on choices - self.assertEqual(len(f.choices), 2) - - # queryset can be changed after the field is created. - f.queryset = Category.objects.exclude(name='Third') - self.assertEqual(list(f.choices), [ - ('', '---------'), - (self.c1.pk, 'Entertainment'), - (self.c2.pk, "It's a test")]) - self.assertEqual(f.clean(self.c2.id).name, "It's a test") - with self.assertRaises(ValidationError): - f.clean(self.c3.id) - - # check that we can safely iterate choices repeatedly - gen_one = list(f.choices) - gen_two = f.choices - self.assertEqual(gen_one[2], (self.c2.pk, "It's a test")) - self.assertEqual(list(gen_two), [ - ('', '---------'), - (self.c1.pk, 'Entertainment'), - (self.c2.pk, "It's a test")]) - - # check that we can override the label_from_instance method to print custom labels (#4620) - f.queryset = Category.objects.all() - f.label_from_instance = lambda obj: "category " + str(obj) - self.assertEqual(list(f.choices), [ - ('', '---------'), - (self.c1.pk, 'category Entertainment'), - (self.c2.pk, "category It's a test"), - (self.c3.pk, 'category Third')]) - - def test_modelchoicefield_11183(self): - """ - Regression test for ticket #11183. - """ - class ModelChoiceForm(forms.Form): - category = forms.ModelChoiceField(Category.objects.all()) - - form1 = ModelChoiceForm() - field1 = form1.fields['category'] - # To allow the widget to change the queryset of field1.widget.choices correctly, - # without affecting other forms, the following must hold: - self.assertIsNot(field1, ModelChoiceForm.base_fields['category']) - self.assertIs(field1.widget.choices.field, field1) - - def test_modelchoicefield_result_cache_not_shared(self): - class ModelChoiceForm(forms.Form): - category = forms.ModelChoiceField(Category.objects.all()) - - form1 = ModelChoiceForm() - self.assertCountEqual(form1.fields['category'].queryset, [self.c1, self.c2, self.c3]) - form2 = ModelChoiceForm() - self.assertIsNone(form2.fields['category'].queryset._result_cache) - - def test_modelchoicefield_queryset_none(self): - class ModelChoiceForm(forms.Form): - category = forms.ModelChoiceField(queryset=None) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['category'].queryset = Category.objects.filter(slug__contains='test') - - form = ModelChoiceForm() - self.assertCountEqual(form.fields['category'].queryset, [self.c2, self.c3]) - - def test_modelchoicefield_22745(self): - """ - #22745 -- Make sure that ModelChoiceField with RadioSelect widget - doesn't produce unnecessary db queries when accessing its BoundField's - attrs. - """ - class ModelChoiceForm(forms.Form): - category = forms.ModelChoiceField(Category.objects.all(), widget=forms.RadioSelect) - - form = ModelChoiceForm() - field = form['category'] # BoundField - template = Template('{{ field.name }}{{ field }}{{ field.help_text }}') - with self.assertNumQueries(1): - template.render(Context({'field': field})) - - def test_disabled_modelchoicefield(self): - class ModelChoiceForm(forms.ModelForm): - author = forms.ModelChoiceField(Author.objects.all(), disabled=True) - - class Meta: - model = Book - fields = ['author'] - - book = Book.objects.create(author=Writer.objects.create(name='Test writer')) - form = ModelChoiceForm({}, instance=book) - self.assertEqual( - form.errors['author'], - ['Select a valid choice. That choice is not one of the available choices.'] - ) - - def test_disabled_modelchoicefield_has_changed(self): - field = forms.ModelChoiceField(Author.objects.all(), disabled=True) - self.assertIs(field.has_changed('x', 'y'), False) - - def test_disabled_multiplemodelchoicefield(self): - class ArticleForm(forms.ModelForm): - categories = forms.ModelMultipleChoiceField(Category.objects.all(), required=False) - - class Meta: - model = Article - fields = ['categories'] - - category1 = Category.objects.create(name='cat1') - category2 = Category.objects.create(name='cat2') - article = Article.objects.create( - pub_date=datetime.date(1988, 1, 4), - writer=Writer.objects.create(name='Test writer'), - ) - article.categories.set([category1.pk]) - - form = ArticleForm(data={'categories': [category2.pk]}, instance=article) - self.assertEqual(form.errors, {}) - self.assertEqual([x.pk for x in form.cleaned_data['categories']], [category2.pk]) - # Disabled fields use the value from `instance` rather than `data`. - form = ArticleForm(data={'categories': [category2.pk]}, instance=article) - form.fields['categories'].disabled = True - self.assertEqual(form.errors, {}) - self.assertEqual([x.pk for x in form.cleaned_data['categories']], [category1.pk]) - - def test_disabled_modelmultiplechoicefield_has_changed(self): - field = forms.ModelMultipleChoiceField(Author.objects.all(), disabled=True) - self.assertIs(field.has_changed('x', 'y'), False) - - def test_modelchoicefield_iterator(self): - """ - Iterator defaults to ModelChoiceIterator and can be overridden with - the iterator attribute on a ModelChoiceField subclass. - """ - field = forms.ModelChoiceField(Category.objects.all()) - self.assertIsInstance(field.choices, ModelChoiceIterator) - - class CustomModelChoiceIterator(ModelChoiceIterator): - pass - - class CustomModelChoiceField(forms.ModelChoiceField): - iterator = CustomModelChoiceIterator - - field = CustomModelChoiceField(Category.objects.all()) - self.assertIsInstance(field.choices, CustomModelChoiceIterator) - - def test_modelchoicefield_iterator_pass_model_to_widget(self): - class CustomModelChoiceValue: - def __init__(self, value, obj): - self.value = value - self.obj = obj - - def __str__(self): - return str(self.value) - - class CustomModelChoiceIterator(ModelChoiceIterator): - def choice(self, obj): - value, label = super().choice(obj) - return CustomModelChoiceValue(value, obj), label - - class CustomCheckboxSelectMultiple(CheckboxSelectMultiple): - def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): - option = super().create_option(name, value, label, selected, index, subindex=None, attrs=None) - # Modify the HTML based on the object being rendered. - c = value.obj - option['attrs']['data-slug'] = c.slug - return option - - class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField): - iterator = CustomModelChoiceIterator - widget = CustomCheckboxSelectMultiple - - field = CustomModelMultipleChoiceField(Category.objects.all()) - self.assertHTMLEqual( - field.widget.render('name', []), - '''''' % (self.c1.pk, self.c2.pk, self.c3.pk), - ) - - def test_modelchoicefield_num_queries(self): - """ - Widgets that render multiple subwidgets shouldn't make more than one - database query. - """ - categories = Category.objects.all() - - class CategoriesForm(forms.Form): - radio = forms.ModelChoiceField(queryset=categories, widget=forms.RadioSelect) - checkbox = forms.ModelMultipleChoiceField(queryset=categories, widget=forms.CheckboxSelectMultiple) - - template = Template(""" - {% for widget in form.checkbox %}{{ widget }}{% endfor %} - {% for widget in form.radio %}{{ widget }}{% endfor %} - """) - - with self.assertNumQueries(2): - template.render(Context({'form': CategoriesForm()})) - - class ModelMultipleChoiceFieldTests(TestCase): def setUp(self): self.c1 = Category.objects.create(