diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index f1e4790568..5f8f59d69f 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -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 " diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index a41eb7b1d2..7ad9f77741 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -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:: diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 5a82c342c0..186768f070 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -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 `:: +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 `:: from django.db import models @@ -177,13 +177,20 @@ 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 +`. Minor features -------------- diff --git a/tests/invalid_models_tests/test_ordinary_fields.py b/tests/invalid_models_tests/test_ordinary_fields.py index affe642ac7..ceeb254e57 100644 --- a/tests/invalid_models_tests/test_ordinary_fields.py +++ b/tests/invalid_models_tests/test_ordinary_fields.py @@ -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") diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py index fd0e7f0fe3..a2ac673804 100644 --- a/tests/migrations/test_writer.py +++ b/tests/migrations/test_writer.py @@ -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__ diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index 7fb0f8b610..b966da59e5 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -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): diff --git a/tests/model_fields/test_charfield.py b/tests/model_fields/test_charfield.py index 782158d210..e841ed807a 100644 --- a/tests/model_fields/test_charfield.py +++ b/tests/model_fields/test_charfield.py @@ -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) diff --git a/tests/model_fields/test_integerfield.py b/tests/model_fields/test_integerfield.py index 0d91cff9eb..34966304fb 100644 --- a/tests/model_fields/test_integerfield.py +++ b/tests/model_fields/test_integerfield.py @@ -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) diff --git a/tests/model_fields/tests.py b/tests/model_fields/tests.py index bc57a00a1d..36e54d4b8b 100644 --- a/tests/model_fields/tests.py +++ b/tests/model_fields/tests.py @@ -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):