mirror of https://github.com/django/django.git
Fixed #24561 -- Added support for callables on model fields' choices.
This commit is contained in:
parent
5bfb3cbf49
commit
691f70c477
|
@ -316,9 +316,7 @@ class Field(RegisterLookupMixin):
|
|||
if not self.choices:
|
||||
return []
|
||||
|
||||
if not is_iterable(self.choices) or isinstance(
|
||||
self.choices, (str, CallableChoiceIterator)
|
||||
):
|
||||
if not is_iterable(self.choices) or isinstance(self.choices, str):
|
||||
return [
|
||||
checks.Error(
|
||||
"'choices' must be a mapping (e.g. a dictionary) or an iterable "
|
||||
|
|
|
@ -115,9 +115,32 @@ human-readable name. For example::
|
|||
("GR", "Graduate"),
|
||||
]
|
||||
|
||||
``choices`` can also be defined as a callable that expects no arguments and
|
||||
returns any of the formats described above. For example::
|
||||
|
||||
def get_currencies():
|
||||
return {i: i for i in settings.CURRENCIES}
|
||||
|
||||
|
||||
class Expense(models.Model):
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
currency = models.CharField(max_length=3, choices=get_currencies)
|
||||
|
||||
Passing a callable for ``choices`` can be particularly handy when, for example,
|
||||
the choices are:
|
||||
|
||||
* the result of I/O-bound operations (which could potentially be cached), such
|
||||
as querying a table in the same or an external database, or accessing the
|
||||
choices from a static file.
|
||||
|
||||
* a list that is mostly stable but could vary from time to time or from
|
||||
project to project. Examples in this category are using third-party apps that
|
||||
provide a well-known inventory of values, such as currencies, countries,
|
||||
languages, time zones, etc.
|
||||
|
||||
.. versionchanged:: 5.0
|
||||
|
||||
Support for mappings was added.
|
||||
Support for mappings and callables was added.
|
||||
|
||||
Generally, it's best to define choices inside a model class, and to
|
||||
define a suitably-named constant for each value::
|
||||
|
|
|
@ -157,14 +157,14 @@ form::
|
|||
]
|
||||
|
||||
|
||||
class Winners(models.Model):
|
||||
class Winner(models.Model):
|
||||
name = models.CharField(...)
|
||||
medal = models.CharField(..., choices=Medal.choices)
|
||||
sport = models.CharField(..., choices=SPORT_CHOICES)
|
||||
|
||||
Django 5.0 supports providing a mapping instead of an iterable, and also no
|
||||
longer requires ``.choices`` to be used directly to expand :ref:`enumeration
|
||||
types <field-choices-enum-types>`::
|
||||
Django 5.0 adds support for accepting a mapping or a callable instead of an
|
||||
iterable, and also no longer requires ``.choices`` to be used directly to
|
||||
expand :ref:`enumeration types <field-choices-enum-types>`::
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
@ -177,13 +177,20 @@ types <field-choices-enum-types>`::
|
|||
}
|
||||
|
||||
|
||||
class Winners(models.Model):
|
||||
def get_scores():
|
||||
return [(i, str(i)) for i in range(10)]
|
||||
|
||||
|
||||
class Winner(models.Model):
|
||||
name = models.CharField(...)
|
||||
medal = models.CharField(..., choices=Medal) # Using `.choices` not required.
|
||||
sport = models.CharField(..., choices=SPORT_CHOICES)
|
||||
score = models.IntegerField(choices=get_scores) # A callable is allowed.
|
||||
|
||||
Under the hood the provided ``choices`` are normalized into a list of 2-tuples
|
||||
as the canonical form whenever the ``choices`` value is updated.
|
||||
as the canonical form whenever the ``choices`` value is updated. For more
|
||||
information, please check the :ref:`model field reference on choices
|
||||
<field-choices>`.
|
||||
|
||||
Minor features
|
||||
--------------
|
||||
|
|
|
@ -391,26 +391,6 @@ class CharFieldTests(TestCase):
|
|||
],
|
||||
)
|
||||
|
||||
def test_choices_callable(self):
|
||||
def get_choices():
|
||||
return [(i, i) for i in range(3)]
|
||||
|
||||
class Model(models.Model):
|
||||
field = models.CharField(max_length=10, choices=get_choices)
|
||||
|
||||
field = Model._meta.get_field("field")
|
||||
self.assertEqual(
|
||||
field.check(),
|
||||
[
|
||||
Error(
|
||||
"'choices' must be a mapping (e.g. a dictionary) or an iterable "
|
||||
"(e.g. a list or tuple).",
|
||||
obj=field,
|
||||
id="fields.E004",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_bad_db_index_value(self):
|
||||
class Model(models.Model):
|
||||
field = models.CharField(max_length=10, db_index="bad")
|
||||
|
|
|
@ -31,6 +31,10 @@ from django.utils.translation import gettext_lazy as _
|
|||
from .models import FoodManager, FoodQuerySet
|
||||
|
||||
|
||||
def get_choices():
|
||||
return [(i, str(i)) for i in range(3)]
|
||||
|
||||
|
||||
class DeconstructibleInstances:
|
||||
def deconstruct(self):
|
||||
return ("DeconstructibleInstances", [], {})
|
||||
|
@ -493,6 +497,14 @@ class WriterTests(SimpleTestCase):
|
|||
"models.IntegerField(choices=[('Group', [(2, '2'), (1, '1')])])",
|
||||
)
|
||||
|
||||
def test_serialize_callable_choices(self):
|
||||
field = models.IntegerField(choices=get_choices)
|
||||
string = MigrationWriter.serialize(field)[0]
|
||||
self.assertEqual(
|
||||
string,
|
||||
"models.IntegerField(choices=migrations.test_writer.get_choices)",
|
||||
)
|
||||
|
||||
def test_serialize_nested_class(self):
|
||||
for nested_cls in [self.NestedEnum, self.NestedChoices]:
|
||||
cls_name = nested_cls.__name__
|
||||
|
|
|
@ -77,6 +77,9 @@ class Choiceful(models.Model):
|
|||
HEART = 3, "Heart"
|
||||
CLUB = 4, "Club"
|
||||
|
||||
def get_choices():
|
||||
return [(i, str(i)) for i in range(3)]
|
||||
|
||||
no_choices = models.IntegerField(null=True)
|
||||
empty_choices = models.IntegerField(choices=(), null=True)
|
||||
with_choices = models.IntegerField(choices=[(1, "A")], null=True)
|
||||
|
@ -88,6 +91,7 @@ class Choiceful(models.Model):
|
|||
empty_choices_text = models.TextField(choices=())
|
||||
choices_from_enum = models.IntegerField(choices=Suit)
|
||||
choices_from_iterator = models.IntegerField(choices=((i, str(i)) for i in range(3)))
|
||||
choices_from_callable = models.IntegerField(choices=get_choices)
|
||||
|
||||
|
||||
class BigD(models.Model):
|
||||
|
|
|
@ -89,3 +89,18 @@ class ValidationTests(SimpleTestCase):
|
|||
msg = "This field cannot be null."
|
||||
with self.assertRaisesMessage(ValidationError, msg):
|
||||
f.clean(None, None)
|
||||
|
||||
def test_callable_choices(self):
|
||||
def get_choices():
|
||||
return {str(i): f"Option {i}" for i in range(3)}
|
||||
|
||||
f = models.CharField(max_length=1, choices=get_choices)
|
||||
|
||||
for i in get_choices():
|
||||
with self.subTest(i=i):
|
||||
self.assertEqual(i, f.clean(i, None))
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
f.clean("A", None)
|
||||
with self.assertRaises(ValidationError):
|
||||
f.clean("3", None)
|
||||
|
|
|
@ -318,3 +318,18 @@ class ValidationTests(SimpleTestCase):
|
|||
f.clean("A", None)
|
||||
with self.assertRaises(ValidationError):
|
||||
f.clean("3", None)
|
||||
|
||||
def test_callable_choices(self):
|
||||
def get_choices():
|
||||
return {i: str(i) for i in range(3)}
|
||||
|
||||
f = models.IntegerField(choices=get_choices)
|
||||
|
||||
for i in get_choices():
|
||||
with self.subTest(i=i):
|
||||
self.assertEqual(i, f.clean(i, None))
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
f.clean("A", None)
|
||||
with self.assertRaises(ValidationError):
|
||||
f.clean("3", None)
|
||||
|
|
|
@ -4,6 +4,7 @@ from django import forms
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.utils.choices import CallableChoiceIterator
|
||||
from django.utils.functional import lazy
|
||||
|
||||
from .models import (
|
||||
|
@ -162,6 +163,7 @@ class ChoicesTests(SimpleTestCase):
|
|||
)
|
||||
cls.choices_from_enum = Choiceful._meta.get_field("choices_from_enum")
|
||||
cls.choices_from_iterator = Choiceful._meta.get_field("choices_from_iterator")
|
||||
cls.choices_from_callable = Choiceful._meta.get_field("choices_from_callable")
|
||||
|
||||
def test_choices(self):
|
||||
self.assertIsNone(self.no_choices.choices)
|
||||
|
@ -174,6 +176,12 @@ class ChoicesTests(SimpleTestCase):
|
|||
self.assertEqual(
|
||||
self.choices_from_iterator.choices, [(0, "0"), (1, "1"), (2, "2")]
|
||||
)
|
||||
self.assertIsInstance(
|
||||
self.choices_from_callable.choices, CallableChoiceIterator
|
||||
)
|
||||
self.assertEqual(
|
||||
self.choices_from_callable.choices.func(), [(0, "0"), (1, "1"), (2, "2")]
|
||||
)
|
||||
|
||||
def test_flatchoices(self):
|
||||
self.assertEqual(self.no_choices.flatchoices, [])
|
||||
|
@ -186,6 +194,9 @@ class ChoicesTests(SimpleTestCase):
|
|||
self.assertEqual(
|
||||
self.choices_from_iterator.flatchoices, [(0, "0"), (1, "1"), (2, "2")]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.choices_from_callable.flatchoices, [(0, "0"), (1, "1"), (2, "2")]
|
||||
)
|
||||
|
||||
def test_check(self):
|
||||
self.assertEqual(Choiceful.check(), [])
|
||||
|
@ -204,9 +215,14 @@ class ChoicesTests(SimpleTestCase):
|
|||
self.assertIsInstance(no_choices_formfield, forms.IntegerField)
|
||||
fields = (
|
||||
self.empty_choices,
|
||||
self.with_choices,
|
||||
self.empty_choices_bool,
|
||||
self.empty_choices_text,
|
||||
self.with_choices,
|
||||
self.with_choices_dict,
|
||||
self.with_choices_nested_dict,
|
||||
self.choices_from_enum,
|
||||
self.choices_from_iterator,
|
||||
self.choices_from_callable,
|
||||
)
|
||||
for field in fields:
|
||||
with self.subTest(field=field):
|
||||
|
|
Loading…
Reference in New Issue