Browse Source

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

Natalia 1 year ago
parent
commit
691f70c477

+ 1 - 3
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 "

+ 24 - 1
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::

+ 13 - 6
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 <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
 --------------

+ 0 - 20
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")

+ 12 - 0
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__

+ 4 - 0
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):

+ 15 - 0
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)

+ 15 - 0
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)

+ 17 - 1
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):