Skip to content

Commit

Permalink
Fixed #34388 -- Allowed using choice enumeration types directly on mo…
Browse files Browse the repository at this point in the history
…del and form fields.
  • Loading branch information
tfranzel authored and felixxm committed Mar 21, 2023
1 parent 051d594 commit a2eaea8
Show file tree
Hide file tree
Showing 12 changed files with 48 additions and 19 deletions.
3 changes: 3 additions & 0 deletions django/db/models/fields/__init__.py
Expand Up @@ -14,6 +14,7 @@
from django.core import checks, exceptions, validators
from django.db import connection, connections, router
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.utils import timezone
from django.utils.datastructures import DictWrapper
Expand Down Expand Up @@ -216,6 +217,8 @@ def __init__(
self.unique_for_date = unique_for_date
self.unique_for_month = unique_for_month
self.unique_for_year = unique_for_year
if isinstance(choices, ChoicesMeta):
choices = choices.choices
if isinstance(choices, collections.abc.Iterator):
choices = list(choices)
self.choices = choices
Expand Down
3 changes: 3 additions & 0 deletions django/forms/fields.py
Expand Up @@ -16,6 +16,7 @@

from django.core import validators
from django.core.exceptions import ValidationError
from django.db.models.enums import ChoicesMeta
from django.forms.boundfield import BoundField
from django.forms.utils import from_current_timezone, to_current_timezone
from django.forms.widgets import (
Expand Down Expand Up @@ -857,6 +858,8 @@ class ChoiceField(Field):

def __init__(self, *, choices=(), **kwargs):
super().__init__(**kwargs)
if isinstance(choices, ChoicesMeta):
choices = choices.choices
self.choices = choices

def __deepcopy__(self, memo):
Expand Down
7 changes: 6 additions & 1 deletion docs/ref/forms/fields.txt
Expand Up @@ -431,14 +431,19 @@ For each field, we describe the default widget used if you don't specify
.. attribute:: choices

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
formats as the ``choices`` argument to a model field. See the
:ref:`model field reference documentation on choices <field-choices>`
for more details. If the argument is a callable, it is evaluated each
time the field's form is initialized, in addition to during rendering.
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``
-------------

Expand Down
11 changes: 7 additions & 4 deletions docs/ref/models/fields.txt
Expand Up @@ -210,7 +210,7 @@ choices in a concise way::

year_in_school = models.CharField(
max_length=2,
choices=YearInSchool.choices,
choices=YearInSchool,
default=YearInSchool.FRESHMAN,
)

Expand All @@ -235,8 +235,7 @@ modifications:
* A ``.label`` property is added on values, to return the human-readable name.
* A number of custom properties are added to the enumeration classes --
``.choices``, ``.labels``, ``.values``, and ``.names`` -- to make it easier
to access lists of those separate parts of the enumeration. Use ``.choices``
as a suitable value to pass to :attr:`~Field.choices` in a field definition.
to access lists of those separate parts of the enumeration.

.. warning::

Expand Down Expand Up @@ -276,7 +275,7 @@ Django provides an ``IntegerChoices`` class. For example::
HEART = 3
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
<https://docs.python.org/3/library/enum.html#functional-api>`_ with the caveat
Expand Down Expand Up @@ -320,6 +319,10 @@ There are some additional caveats to be aware of:

__empty__ = _("(Unknown)")

.. versionchanged:: 5.0

Support for using enumeration types directly in the ``choices`` was added.

``db_column``
-------------

Expand Down
8 changes: 7 additions & 1 deletion docs/releases/5.0.txt
Expand Up @@ -167,7 +167,9 @@ File Uploads
Forms
~~~~~

* ...
* :attr:`.ChoiceField.choices` now accepts
:ref:`Choices classes <field-choices-enum-types>` directly instead of
requiring expansion with the ``choices`` attribute.

Generic Views
~~~~~~~~~~~~~
Expand Down Expand Up @@ -208,6 +210,10 @@ Models
of ``ValidationError`` raised during
: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
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion docs/topics/db/models.txt
Expand Up @@ -211,7 +211,7 @@ ones:
class Runner(models.Model):
MedalType = models.TextChoices("MedalType", "GOLD SILVER BRONZE")
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
<field-choices>`.
Expand Down
3 changes: 2 additions & 1 deletion tests/forms_tests/field_tests/test_choicefield.py
Expand Up @@ -95,7 +95,8 @@ class FirstNames(models.TextChoices):
JOHN = "J", "John"
PAUL = "P", "Paul"

f = ChoiceField(choices=FirstNames.choices)
f = ChoiceField(choices=FirstNames)
self.assertEqual(f.choices, FirstNames.choices)
self.assertEqual(f.clean("J"), "J")
msg = "'Select a valid choice. 3 is not one of the available choices.'"
with self.assertRaisesMessage(ValidationError, msg):
Expand Down
10 changes: 3 additions & 7 deletions tests/migrations/test_writer.py
Expand Up @@ -433,24 +433,20 @@ class DateChoices(datetime.date, models.Choices):
DateChoices.DATE_1,
("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]
self.assertEqual(
string,
"models.CharField(choices=[('A', 'A value'), ('B', 'B value')], "
"default='B')",
)
field = models.IntegerField(
default=IntegerChoices.B, choices=IntegerChoices.choices
)
field = models.IntegerField(default=IntegerChoices.B, choices=IntegerChoices)
string = MigrationWriter.serialize(field)[0]
self.assertEqual(
string,
"models.IntegerField(choices=[(1, 'One'), (2, 'Two')], default=2)",
)
field = models.DateField(
default=DateChoices.DATE_2, choices=DateChoices.choices
)
field = models.DateField(default=DateChoices.DATE_2, choices=DateChoices)
string = MigrationWriter.serialize(field)[0]
self.assertEqual(
string,
Expand Down
7 changes: 7 additions & 0 deletions tests/model_fields/models.py
Expand Up @@ -69,11 +69,18 @@ class WhizIterEmpty(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)
empty_choices = models.IntegerField(choices=(), null=True)
with_choices = models.IntegerField(choices=[(1, "A")], null=True)
empty_choices_bool = models.BooleanField(choices=())
empty_choices_text = models.TextField(choices=())
choices_from_enum = models.IntegerField(choices=Suit)


class BigD(models.Model):
Expand Down
4 changes: 2 additions & 2 deletions tests/model_fields/test_charfield.py
Expand Up @@ -75,11 +75,11 @@ def test_charfield_with_choices_raises_error_on_invalid_choice(self):
f.clean("not a", None)

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")

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."
with self.assertRaisesMessage(ValidationError, msg):
f.clean("a", None)
Expand Down
4 changes: 2 additions & 2 deletions tests/model_fields/test_integerfield.py
Expand Up @@ -301,11 +301,11 @@ def test_integerfield_validates_zero_against_choices(self):
f.clean("0", None)

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)

def test_enum_choices_invalid_input(self):
f = models.IntegerField(choices=self.Choices.choices)
f = models.IntegerField(choices=self.Choices)
with self.assertRaises(ValidationError):
f.clean("A", None)
with self.assertRaises(ValidationError):
Expand Down
5 changes: 5 additions & 0 deletions tests/model_fields/tests.py
Expand Up @@ -156,6 +156,7 @@ def setUpClass(cls):
cls.empty_choices_bool = Choiceful._meta.get_field("empty_choices_bool")
cls.empty_choices_text = Choiceful._meta.get_field("empty_choices_text")
cls.with_choices = Choiceful._meta.get_field("with_choices")
cls.choices_from_enum = Choiceful._meta.get_field("choices_from_enum")

def test_choices(self):
self.assertIsNone(self.no_choices.choices)
Expand Down Expand Up @@ -192,6 +193,10 @@ def test_formfield(self):
with self.subTest(field=field):
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):
def test_choices_and_field_display(self):
Expand Down

0 comments on commit a2eaea8

Please sign in to comment.