mirror of
				https://github.com/django/django.git
				synced 2025-10-31 09:41:08 +00:00 
			
		
		
		
	Fixed #24561 -- Added support for callables on model fields' choices.
This commit is contained in:
		| @@ -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): | ||||
|   | ||||
		Reference in New Issue
	
	Block a user