Fixed #34388 -- Allowed using choice enumeration types directly on model and form fields.

This commit is contained in:
T. Franzel 2023-03-11 00:34:13 +01:00 committed by Mariusz Felisiak
parent 051d5944f8
commit a2eaea8f22
12 changed files with 48 additions and 19 deletions

View File

@ -14,6 +14,7 @@ from django.conf import settings
from django.core import checks, exceptions, validators from django.core import checks, exceptions, validators
from django.db import connection, connections, router from django.db import connection, connections, router
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
from django.db.models.enums import ChoicesMeta
from django.db.models.query_utils import DeferredAttribute, RegisterLookupMixin from django.db.models.query_utils import DeferredAttribute, RegisterLookupMixin
from django.utils import timezone from django.utils import timezone
from django.utils.datastructures import DictWrapper from django.utils.datastructures import DictWrapper
@ -216,6 +217,8 @@ class Field(RegisterLookupMixin):
self.unique_for_date = unique_for_date self.unique_for_date = unique_for_date
self.unique_for_month = unique_for_month self.unique_for_month = unique_for_month
self.unique_for_year = unique_for_year self.unique_for_year = unique_for_year
if isinstance(choices, ChoicesMeta):
choices = choices.choices
if isinstance(choices, collections.abc.Iterator): if isinstance(choices, collections.abc.Iterator):
choices = list(choices) choices = list(choices)
self.choices = choices self.choices = choices

View File

@ -16,6 +16,7 @@ from urllib.parse import urlsplit, urlunsplit
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.enums import ChoicesMeta
from django.forms.boundfield import BoundField from django.forms.boundfield import BoundField
from django.forms.utils import from_current_timezone, to_current_timezone from django.forms.utils import from_current_timezone, to_current_timezone
from django.forms.widgets import ( from django.forms.widgets import (
@ -857,6 +858,8 @@ class ChoiceField(Field):
def __init__(self, *, choices=(), **kwargs): def __init__(self, *, choices=(), **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
if isinstance(choices, ChoicesMeta):
choices = choices.choices
self.choices = choices self.choices = choices
def __deepcopy__(self, memo): def __deepcopy__(self, memo):

View File

@ -431,7 +431,7 @@ For each field, we describe the default widget used if you don't specify
.. attribute:: choices .. attribute:: choices
Either an :term:`iterable` of 2-tuples to use as choices for this Either an :term:`iterable` of 2-tuples to use as choices for this
field, :ref:`enumeration <field-choices-enum-types>` choices, or a field, :ref:`enumeration type <field-choices-enum-types>`, or a
callable that returns such an iterable. This argument accepts the same callable that returns such an iterable. This argument accepts the same
formats as the ``choices`` argument to a model field. See the formats as the ``choices`` argument to a model field. See the
:ref:`model field reference documentation on choices <field-choices>` :ref:`model field reference documentation on choices <field-choices>`
@ -439,6 +439,11 @@ For each field, we describe the default widget used if you don't specify
time the field's form is initialized, in addition to during rendering. time the field's form is initialized, in addition to during rendering.
Defaults to an empty list. Defaults to an empty list.
.. versionchanged:: 5.0
Support for using :ref:`enumeration types <field-choices-enum-types>`
directly in the ``choices`` was added.
``DateField`` ``DateField``
------------- -------------

View File

@ -210,7 +210,7 @@ choices in a concise way::
year_in_school = models.CharField( year_in_school = models.CharField(
max_length=2, max_length=2,
choices=YearInSchool.choices, choices=YearInSchool,
default=YearInSchool.FRESHMAN, default=YearInSchool.FRESHMAN,
) )
@ -235,8 +235,7 @@ modifications:
* A ``.label`` property is added on values, to return the human-readable name. * A ``.label`` property is added on values, to return the human-readable name.
* A number of custom properties are added to the enumeration classes -- * A number of custom properties are added to the enumeration classes --
``.choices``, ``.labels``, ``.values``, and ``.names`` -- to make it easier ``.choices``, ``.labels``, ``.values``, and ``.names`` -- to make it easier
to access lists of those separate parts of the enumeration. Use ``.choices`` to access lists of those separate parts of the enumeration.
as a suitable value to pass to :attr:`~Field.choices` in a field definition.
.. warning:: .. warning::
@ -276,7 +275,7 @@ Django provides an ``IntegerChoices`` class. For example::
HEART = 3 HEART = 3
CLUB = 4 CLUB = 4
suit = models.IntegerField(choices=Suit.choices) suit = models.IntegerField(choices=Suit)
It is also possible to make use of the `Enum Functional API It is also possible to make use of the `Enum Functional API
<https://docs.python.org/3/library/enum.html#functional-api>`_ with the caveat <https://docs.python.org/3/library/enum.html#functional-api>`_ with the caveat
@ -320,6 +319,10 @@ There are some additional caveats to be aware of:
__empty__ = _("(Unknown)") __empty__ = _("(Unknown)")
.. versionchanged:: 5.0
Support for using enumeration types directly in the ``choices`` was added.
``db_column`` ``db_column``
------------- -------------

View File

@ -167,7 +167,9 @@ File Uploads
Forms Forms
~~~~~ ~~~~~
* ... * :attr:`.ChoiceField.choices` now accepts
:ref:`Choices classes <field-choices-enum-types>` directly instead of
requiring expansion with the ``choices`` attribute.
Generic Views Generic Views
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@ -208,6 +210,10 @@ Models
of ``ValidationError`` raised during of ``ValidationError`` raised during
:ref:`model validation <validating-objects>`. :ref:`model validation <validating-objects>`.
* :attr:`.Field.choices` now accepts
:ref:`Choices classes <field-choices-enum-types>` directly instead of
requiring expansion with the ``choices`` attribute.
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~

View File

@ -211,7 +211,7 @@ ones:
class Runner(models.Model): class Runner(models.Model):
MedalType = models.TextChoices("MedalType", "GOLD SILVER BRONZE") MedalType = models.TextChoices("MedalType", "GOLD SILVER BRONZE")
name = models.CharField(max_length=60) name = models.CharField(max_length=60)
medal = models.CharField(blank=True, choices=MedalType.choices, max_length=10) medal = models.CharField(blank=True, choices=MedalType, max_length=10)
Further examples are available in the :ref:`model field reference Further examples are available in the :ref:`model field reference
<field-choices>`. <field-choices>`.

View File

@ -95,7 +95,8 @@ class ChoiceFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
JOHN = "J", "John" JOHN = "J", "John"
PAUL = "P", "Paul" PAUL = "P", "Paul"
f = ChoiceField(choices=FirstNames.choices) f = ChoiceField(choices=FirstNames)
self.assertEqual(f.choices, FirstNames.choices)
self.assertEqual(f.clean("J"), "J") self.assertEqual(f.clean("J"), "J")
msg = "'Select a valid choice. 3 is not one of the available choices.'" msg = "'Select a valid choice. 3 is not one of the available choices.'"
with self.assertRaisesMessage(ValidationError, msg): with self.assertRaisesMessage(ValidationError, msg):

View File

@ -433,24 +433,20 @@ class WriterTests(SimpleTestCase):
DateChoices.DATE_1, DateChoices.DATE_1,
("datetime.date(1969, 7, 20)", {"import datetime"}), ("datetime.date(1969, 7, 20)", {"import datetime"}),
) )
field = models.CharField(default=TextChoices.B, choices=TextChoices.choices) field = models.CharField(default=TextChoices.B, choices=TextChoices)
string = MigrationWriter.serialize(field)[0] string = MigrationWriter.serialize(field)[0]
self.assertEqual( self.assertEqual(
string, string,
"models.CharField(choices=[('A', 'A value'), ('B', 'B value')], " "models.CharField(choices=[('A', 'A value'), ('B', 'B value')], "
"default='B')", "default='B')",
) )
field = models.IntegerField( field = models.IntegerField(default=IntegerChoices.B, choices=IntegerChoices)
default=IntegerChoices.B, choices=IntegerChoices.choices
)
string = MigrationWriter.serialize(field)[0] string = MigrationWriter.serialize(field)[0]
self.assertEqual( self.assertEqual(
string, string,
"models.IntegerField(choices=[(1, 'One'), (2, 'Two')], default=2)", "models.IntegerField(choices=[(1, 'One'), (2, 'Two')], default=2)",
) )
field = models.DateField( field = models.DateField(default=DateChoices.DATE_2, choices=DateChoices)
default=DateChoices.DATE_2, choices=DateChoices.choices
)
string = MigrationWriter.serialize(field)[0] string = MigrationWriter.serialize(field)[0]
self.assertEqual( self.assertEqual(
string, string,

View File

@ -69,11 +69,18 @@ class WhizIterEmpty(models.Model):
class Choiceful(models.Model): class Choiceful(models.Model):
class Suit(models.IntegerChoices):
DIAMOND = 1, "Diamond"
SPADE = 2, "Spade"
HEART = 3, "Heart"
CLUB = 4, "Club"
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)
empty_choices_bool = models.BooleanField(choices=()) empty_choices_bool = models.BooleanField(choices=())
empty_choices_text = models.TextField(choices=()) empty_choices_text = models.TextField(choices=())
choices_from_enum = models.IntegerField(choices=Suit)
class BigD(models.Model): class BigD(models.Model):

View File

@ -75,11 +75,11 @@ class ValidationTests(SimpleTestCase):
f.clean("not a", None) f.clean("not a", None)
def test_enum_choices_cleans_valid_string(self): def test_enum_choices_cleans_valid_string(self):
f = models.CharField(choices=self.Choices.choices, max_length=1) f = models.CharField(choices=self.Choices, max_length=1)
self.assertEqual(f.clean("c", None), "c") self.assertEqual(f.clean("c", None), "c")
def test_enum_choices_invalid_input(self): def test_enum_choices_invalid_input(self):
f = models.CharField(choices=self.Choices.choices, max_length=1) f = models.CharField(choices=self.Choices, max_length=1)
msg = "Value 'a' is not a valid choice." msg = "Value 'a' is not a valid choice."
with self.assertRaisesMessage(ValidationError, msg): with self.assertRaisesMessage(ValidationError, msg):
f.clean("a", None) f.clean("a", None)

View File

@ -301,11 +301,11 @@ class ValidationTests(SimpleTestCase):
f.clean("0", None) f.clean("0", None)
def test_enum_choices_cleans_valid_string(self): def test_enum_choices_cleans_valid_string(self):
f = models.IntegerField(choices=self.Choices.choices) f = models.IntegerField(choices=self.Choices)
self.assertEqual(f.clean("1", None), 1) self.assertEqual(f.clean("1", None), 1)
def test_enum_choices_invalid_input(self): def test_enum_choices_invalid_input(self):
f = models.IntegerField(choices=self.Choices.choices) f = models.IntegerField(choices=self.Choices)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
f.clean("A", None) f.clean("A", None)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):

View File

@ -156,6 +156,7 @@ class ChoicesTests(SimpleTestCase):
cls.empty_choices_bool = Choiceful._meta.get_field("empty_choices_bool") cls.empty_choices_bool = Choiceful._meta.get_field("empty_choices_bool")
cls.empty_choices_text = Choiceful._meta.get_field("empty_choices_text") cls.empty_choices_text = Choiceful._meta.get_field("empty_choices_text")
cls.with_choices = Choiceful._meta.get_field("with_choices") cls.with_choices = Choiceful._meta.get_field("with_choices")
cls.choices_from_enum = Choiceful._meta.get_field("choices_from_enum")
def test_choices(self): def test_choices(self):
self.assertIsNone(self.no_choices.choices) self.assertIsNone(self.no_choices.choices)
@ -192,6 +193,10 @@ class ChoicesTests(SimpleTestCase):
with self.subTest(field=field): with self.subTest(field=field):
self.assertIsInstance(field.formfield(), forms.ChoiceField) self.assertIsInstance(field.formfield(), forms.ChoiceField)
def test_choices_from_enum(self):
# Choices class was transparently resolved when given as argument.
self.assertEqual(self.choices_from_enum.choices, Choiceful.Suit.choices)
class GetFieldDisplayTests(SimpleTestCase): class GetFieldDisplayTests(SimpleTestCase):
def test_choices_and_field_display(self): def test_choices_and_field_display(self):