diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 6174b7bc98..205a41c193 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -16,6 +16,7 @@ from django.db.models.constants import LOOKUP_SEP from django.db.models.query_utils import DeferredAttribute, RegisterLookupMixin from django.utils import timezone from django.utils.choices import ( + BlankChoiceIterator, CallableChoiceIterator, flatten_choices, normalize_choices, @@ -1055,14 +1056,9 @@ class Field(RegisterLookupMixin): as <select> choices for this field. """ if self.choices is not None: - choices = list(self.choices) if include_blank: - blank_defined = any( - choice in ("", None) for choice, _ in self.flatchoices - ) - if not blank_defined: - choices = blank_choice + choices - return choices + return BlankChoiceIterator(self.choices, blank_choice) + return self.choices rel_model = self.remote_field.model limit_choices_to = limit_choices_to or self.get_limit_choices_to() choice_func = operator.attrgetter( diff --git a/django/utils/choices.py b/django/utils/choices.py index 54dbdcb3aa..7f40bce510 100644 --- a/django/utils/choices.py +++ b/django/utils/choices.py @@ -1,10 +1,11 @@ from collections.abc import Callable, Iterable, Iterator, Mapping -from itertools import islice, zip_longest +from itertools import islice, tee, zip_longest from django.utils.functional import Promise __all__ = [ "BaseChoiceIterator", + "BlankChoiceIterator", "CallableChoiceIterator", "flatten_choices", "normalize_choices", @@ -34,6 +35,20 @@ class BaseChoiceIterator: ) +class BlankChoiceIterator(BaseChoiceIterator): + """Iterator to lazily inject a blank choice.""" + + def __init__(self, choices, blank_choice): + self.choices = choices + self.blank_choice = blank_choice + + def __iter__(self): + choices, other = tee(self.choices) + if not any(value in ("", None) for value, _ in flatten_choices(other)): + yield from self.blank_choice + yield from choices + + class CallableChoiceIterator(BaseChoiceIterator): """Iterator to lazily normalize choices generated by a callable.""" diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index d1716ce201..43bb770f7e 100644 --- a/tests/model_forms/tests.py +++ b/tests/model_forms/tests.py @@ -23,6 +23,7 @@ from django.forms.models import ( from django.template import Context, Template from django.test import SimpleTestCase, TestCase, ignore_warnings, skipUnlessDBFeature from django.test.utils import isolate_apps +from django.utils.choices import BlankChoiceIterator from django.utils.deprecation import RemovedInDjango60Warning from .models import ( @@ -2012,6 +2013,38 @@ class ModelFormBasicTests(TestCase): ), ) + @isolate_apps("model_forms") + def test_callable_choices_are_lazy(self): + call_count = 0 + + def get_animal_choices(): + nonlocal call_count + call_count += 1 + return [("LION", "Lion"), ("ZEBRA", "Zebra")] + + class ZooKeeper(models.Model): + animal = models.CharField( + blank=True, + choices=get_animal_choices, + max_length=5, + ) + + class ZooKeeperForm(forms.ModelForm): + class Meta: + model = ZooKeeper + fields = ["animal"] + + self.assertEqual(call_count, 0) + form = ZooKeeperForm() + self.assertEqual(call_count, 0) + self.assertIsInstance(form.fields["animal"].choices, BlankChoiceIterator) + self.assertEqual(call_count, 0) + self.assertEqual( + form.fields["animal"].choices, + models.BLANK_CHOICE_DASH + [("LION", "Lion"), ("ZEBRA", "Zebra")], + ) + self.assertEqual(call_count, 1) + def test_recleaning_model_form_instance(self): """ Re-cleaning an instance that was added via a ModelForm shouldn't raise