summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNick Pope <nick@nickpope.me.uk>2023-10-16 19:11:18 +0100
committerNatalia <124304+nessita@users.noreply.github.com>2023-10-23 14:54:37 -0300
commitcc5901fa8edc25ce6d67d110c18ddf9f16965e32 (patch)
treed0af62584c37f39f20caf04fab8193c1823a0d25
parentbbe90f3c00eb29a8e86b1b638466029def7f444a (diff)
[5.0.x] Fixed #34899 -- Added blank choice to forms' callable choices lazily.
Backport of 171f91d9ef5177850c2f12b26dd732785f6ac034 from main
-rw-r--r--django/db/models/fields/__init__.py10
-rw-r--r--django/utils/choices.py17
-rw-r--r--tests/model_forms/tests.py33
3 files changed, 52 insertions, 8 deletions
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