mirror of
https://github.com/django/django.git
synced 2025-01-03 06:55:47 +00:00
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:
|
if not self.choices:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not is_iterable(self.choices) or isinstance(
|
if not is_iterable(self.choices) or isinstance(self.choices, str):
|
||||||
self.choices, (str, CallableChoiceIterator)
|
|
||||||
):
|
|
||||||
return [
|
return [
|
||||||
checks.Error(
|
checks.Error(
|
||||||
"'choices' must be a mapping (e.g. a dictionary) or an iterable "
|
"'choices' must be a mapping (e.g. a dictionary) or an iterable "
|
||||||
|
@ -115,9 +115,32 @@ human-readable name. For example::
|
|||||||
("GR", "Graduate"),
|
("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
|
.. 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
|
Generally, it's best to define choices inside a model class, and to
|
||||||
define a suitably-named constant for each value::
|
define a suitably-named constant for each value::
|
||||||
|
@ -157,14 +157,14 @@ form::
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Winners(models.Model):
|
class Winner(models.Model):
|
||||||
name = models.CharField(...)
|
name = models.CharField(...)
|
||||||
medal = models.CharField(..., choices=Medal.choices)
|
medal = models.CharField(..., choices=Medal.choices)
|
||||||
sport = models.CharField(..., choices=SPORT_CHOICES)
|
sport = models.CharField(..., choices=SPORT_CHOICES)
|
||||||
|
|
||||||
Django 5.0 supports providing a mapping instead of an iterable, and also no
|
Django 5.0 adds support for accepting a mapping or a callable instead of an
|
||||||
longer requires ``.choices`` to be used directly to expand :ref:`enumeration
|
iterable, and also no longer requires ``.choices`` to be used directly to
|
||||||
types <field-choices-enum-types>`::
|
expand :ref:`enumeration types <field-choices-enum-types>`::
|
||||||
|
|
||||||
from django.db import models
|
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(...)
|
name = models.CharField(...)
|
||||||
medal = models.CharField(..., choices=Medal) # Using `.choices` not required.
|
medal = models.CharField(..., choices=Medal) # Using `.choices` not required.
|
||||||
sport = models.CharField(..., choices=SPORT_CHOICES)
|
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
|
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
|
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):
|
def test_bad_db_index_value(self):
|
||||||
class Model(models.Model):
|
class Model(models.Model):
|
||||||
field = models.CharField(max_length=10, db_index="bad")
|
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
|
from .models import FoodManager, FoodQuerySet
|
||||||
|
|
||||||
|
|
||||||
|
def get_choices():
|
||||||
|
return [(i, str(i)) for i in range(3)]
|
||||||
|
|
||||||
|
|
||||||
class DeconstructibleInstances:
|
class DeconstructibleInstances:
|
||||||
def deconstruct(self):
|
def deconstruct(self):
|
||||||
return ("DeconstructibleInstances", [], {})
|
return ("DeconstructibleInstances", [], {})
|
||||||
@ -493,6 +497,14 @@ class WriterTests(SimpleTestCase):
|
|||||||
"models.IntegerField(choices=[('Group', [(2, '2'), (1, '1')])])",
|
"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):
|
def test_serialize_nested_class(self):
|
||||||
for nested_cls in [self.NestedEnum, self.NestedChoices]:
|
for nested_cls in [self.NestedEnum, self.NestedChoices]:
|
||||||
cls_name = nested_cls.__name__
|
cls_name = nested_cls.__name__
|
||||||
|
@ -77,6 +77,9 @@ class Choiceful(models.Model):
|
|||||||
HEART = 3, "Heart"
|
HEART = 3, "Heart"
|
||||||
CLUB = 4, "Club"
|
CLUB = 4, "Club"
|
||||||
|
|
||||||
|
def get_choices():
|
||||||
|
return [(i, str(i)) for i in range(3)]
|
||||||
|
|
||||||
no_choices = models.IntegerField(null=True)
|
no_choices = models.IntegerField(null=True)
|
||||||
empty_choices = models.IntegerField(choices=(), null=True)
|
empty_choices = models.IntegerField(choices=(), null=True)
|
||||||
with_choices = models.IntegerField(choices=[(1, "A")], 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=())
|
empty_choices_text = models.TextField(choices=())
|
||||||
choices_from_enum = models.IntegerField(choices=Suit)
|
choices_from_enum = models.IntegerField(choices=Suit)
|
||||||
choices_from_iterator = models.IntegerField(choices=((i, str(i)) for i in range(3)))
|
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):
|
class BigD(models.Model):
|
||||||
|
@ -89,3 +89,18 @@ class ValidationTests(SimpleTestCase):
|
|||||||
msg = "This field cannot be null."
|
msg = "This field cannot be null."
|
||||||
with self.assertRaisesMessage(ValidationError, msg):
|
with self.assertRaisesMessage(ValidationError, msg):
|
||||||
f.clean(None, None)
|
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)
|
f.clean("A", None)
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
f.clean("3", None)
|
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.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.test import SimpleTestCase, TestCase
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
from django.utils.choices import CallableChoiceIterator
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -162,6 +163,7 @@ class ChoicesTests(SimpleTestCase):
|
|||||||
)
|
)
|
||||||
cls.choices_from_enum = Choiceful._meta.get_field("choices_from_enum")
|
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_iterator = Choiceful._meta.get_field("choices_from_iterator")
|
||||||
|
cls.choices_from_callable = Choiceful._meta.get_field("choices_from_callable")
|
||||||
|
|
||||||
def test_choices(self):
|
def test_choices(self):
|
||||||
self.assertIsNone(self.no_choices.choices)
|
self.assertIsNone(self.no_choices.choices)
|
||||||
@ -174,6 +176,12 @@ class ChoicesTests(SimpleTestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.choices_from_iterator.choices, [(0, "0"), (1, "1"), (2, "2")]
|
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):
|
def test_flatchoices(self):
|
||||||
self.assertEqual(self.no_choices.flatchoices, [])
|
self.assertEqual(self.no_choices.flatchoices, [])
|
||||||
@ -186,6 +194,9 @@ class ChoicesTests(SimpleTestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.choices_from_iterator.flatchoices, [(0, "0"), (1, "1"), (2, "2")]
|
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):
|
def test_check(self):
|
||||||
self.assertEqual(Choiceful.check(), [])
|
self.assertEqual(Choiceful.check(), [])
|
||||||
@ -204,9 +215,14 @@ class ChoicesTests(SimpleTestCase):
|
|||||||
self.assertIsInstance(no_choices_formfield, forms.IntegerField)
|
self.assertIsInstance(no_choices_formfield, forms.IntegerField)
|
||||||
fields = (
|
fields = (
|
||||||
self.empty_choices,
|
self.empty_choices,
|
||||||
self.with_choices,
|
|
||||||
self.empty_choices_bool,
|
self.empty_choices_bool,
|
||||||
self.empty_choices_text,
|
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:
|
for field in fields:
|
||||||
with self.subTest(field=field):
|
with self.subTest(field=field):
|
||||||
|
Loading…
Reference in New Issue
Block a user