summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNatalia <124304+nessita@users.noreply.github.com>2023-08-31 09:09:30 -0300
committerNatalia <124304+nessita@users.noreply.github.com>2023-09-14 10:15:33 -0300
commit691f70c47755c7f55fa75ce607d028f81468b745 (patch)
tree4d791a30d85e51c0362dfa6fb85a518758affeb5
parent5bfb3cbf49e2b9701e7c42989e14a72374adb6bd (diff)
Fixed #24561 -- Added support for callables on model fields' choices.
-rw-r--r--django/db/models/fields/__init__.py4
-rw-r--r--docs/ref/models/fields.txt25
-rw-r--r--docs/releases/5.0.txt19
-rw-r--r--tests/invalid_models_tests/test_ordinary_fields.py20
-rw-r--r--tests/migrations/test_writer.py12
-rw-r--r--tests/model_fields/models.py4
-rw-r--r--tests/model_fields/test_charfield.py15
-rw-r--r--tests/model_fields/test_integerfield.py15
-rw-r--r--tests/model_fields/tests.py18
9 files changed, 101 insertions, 31 deletions
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 <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
--------------
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):