1
0
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:
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: 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 "

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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