Fixed #24561 -- Added support for callables on model fields' choices.

This commit is contained in:
Natalia 2023-08-31 09:09:30 -03:00
parent 5bfb3cbf49
commit 691f70c477
9 changed files with 101 additions and 31 deletions

View File

@ -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 "

View File

@ -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::

View File

@ -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
--------------

View File

@ -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")

View File

@ -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__

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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):