Skip to content

Commit

Permalink
Fixed #31262 -- Added support for mappings on model fields and Choic…
Browse files Browse the repository at this point in the history
…eField's choices.
  • Loading branch information
ngnpope committed Aug 31, 2023
1 parent 68a8996 commit 500e010
Show file tree
Hide file tree
Showing 29 changed files with 823 additions and 250 deletions.
10 changes: 8 additions & 2 deletions django/contrib/admin/widgets.py
Expand Up @@ -262,7 +262,6 @@ def __init__(
):
self.needs_multipart_form = widget.needs_multipart_form
self.attrs = widget.attrs
self.choices = widget.choices
self.widget = widget
self.rel = rel
# Backwards compatible check for whether a user can add related
Expand Down Expand Up @@ -295,6 +294,14 @@ def is_hidden(self):
def media(self):
return self.widget.media

@property
def choices(self):
return self.widget.choices

@choices.setter
def choices(self, value):
self.widget.choices = value

def get_related_url(self, info, action, *args):
return reverse(
"admin:%s_%s_%s" % (info + (action,)),
Expand All @@ -307,7 +314,6 @@ def get_context(self, name, value, attrs):

rel_opts = self.rel.model._meta
info = (rel_opts.app_label, rel_opts.model_name)
self.widget.choices = self.choices
related_field_name = self.rel.get_related_field().name
url_params = "&".join(
"%s=%s" % param
Expand Down
31 changes: 18 additions & 13 deletions django/db/models/fields/__init__.py
@@ -1,4 +1,3 @@
import collections.abc
import copy
import datetime
import decimal
Expand All @@ -14,9 +13,9 @@
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.choices import CallableChoiceIterator, normalize_choices
from django.utils.datastructures import DictWrapper
from django.utils.dateparse import (
parse_date,
Expand Down Expand Up @@ -225,10 +224,6 @@ 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
self.help_text = help_text
self.db_index = db_index
Expand Down Expand Up @@ -320,10 +315,13 @@ def _check_choices(self):
if not self.choices:
return []

if not is_iterable(self.choices) or isinstance(self.choices, str):
if not is_iterable(self.choices) or isinstance(
self.choices, (str, CallableChoiceIterator)
):
return [
checks.Error(
"'choices' must be an iterable (e.g., a list or tuple).",
"'choices' must be a mapping (e.g. a dictionary) or an iterable "
"(e.g. a list or tuple).",
obj=self,
id="fields.E004",
)
Expand Down Expand Up @@ -381,8 +379,8 @@ def _check_choices(self):

return [
checks.Error(
"'choices' must be an iterable containing "
"(actual value, human readable name) tuples.",
"'choices' must be a mapping of actual values to human readable names "
"or an iterable containing (actual value, human readable name) tuples.",
obj=self,
id="fields.E005",
)
Expand Down Expand Up @@ -543,6 +541,14 @@ def get_col(self, alias, output_field=None):

return Col(alias, self, output_field)

@property
def choices(self):
return self._choices

@choices.setter
def choices(self, value):
self._choices = normalize_choices(value)

@cached_property
def cached_col(self):
from django.db.models.expressions import Col
Expand Down Expand Up @@ -625,9 +631,8 @@ def deconstruct(self):
equals_comparison = {"choices", "validators"}
for name, default in possibles.items():
value = getattr(self, attr_overrides.get(name, name))
# Unroll anything iterable for choices into a concrete list
if name == "choices" and isinstance(value, collections.abc.Iterable):
value = list(value)
if isinstance(value, CallableChoiceIterator):
value = value.func
# Do correct kind of comparison
if name in equals_comparison:
if value != default:
Expand Down
32 changes: 8 additions & 24 deletions django/forms/fields.py
Expand Up @@ -17,7 +17,6 @@

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 All @@ -42,6 +41,7 @@
URLInput,
)
from django.utils import formats
from django.utils.choices import normalize_choices
from django.utils.dateparse import parse_datetime, parse_duration
from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.duration import duration_string
Expand Down Expand Up @@ -861,14 +861,6 @@ def validate(self, value):
pass


class CallableChoiceIterator:
def __init__(self, choices_func):
self.choices_func = choices_func

def __iter__(self):
yield from self.choices_func()


class ChoiceField(Field):
widget = Select
default_error_messages = {
Expand All @@ -879,30 +871,22 @@ 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):
result = super().__deepcopy__(memo)
result._choices = copy.deepcopy(self._choices, memo)
return result

def _get_choices(self):
@property
def choices(self):
return self._choices

def _set_choices(self, value):
# Setting choices also sets the choices on the widget.
# choices can be any iterable, but we call list() on it because
# it will be consumed more than once.
if callable(value):
value = CallableChoiceIterator(value)
else:
value = list(value)

self._choices = self.widget.choices = value

choices = property(_get_choices, _set_choices)
@choices.setter
def choices(self, value):
# Setting choices on the field also sets the choices on the widget.
# Note that the property setter for the widget will re-normalize.
self._choices = self.widget.choices = normalize_choices(value)

def to_python(self, value):
"""Return a string."""
Expand Down
5 changes: 3 additions & 2 deletions django/forms/models.py
Expand Up @@ -21,6 +21,7 @@
RadioSelect,
SelectMultiple,
)
from django.utils.choices import ChoiceIterator
from django.utils.text import capfirst, get_text_list
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -1402,7 +1403,7 @@ def __eq__(self, other):
return self.value == other


class ModelChoiceIterator:
class ModelChoiceIterator(ChoiceIterator):
def __init__(self, field):
self.field = field
self.queryset = field.queryset
Expand Down Expand Up @@ -1532,7 +1533,7 @@ def _get_choices(self):
# the queryset.
return self.iterator(self)

choices = property(_get_choices, ChoiceField._set_choices)
choices = property(_get_choices, ChoiceField.choices.fset)

def prepare_value(self, value):
if hasattr(value, "_meta"):
Expand Down
14 changes: 10 additions & 4 deletions django/forms/widgets.py
Expand Up @@ -12,6 +12,7 @@
from django.forms.utils import to_current_timezone
from django.templatetags.static import static
from django.utils import formats
from django.utils.choices import normalize_choices
from django.utils.dates import MONTHS
from django.utils.formats import get_format
from django.utils.html import format_html, html_safe
Expand Down Expand Up @@ -620,10 +621,7 @@ class ChoiceWidget(Widget):

def __init__(self, attrs=None, choices=()):
super().__init__(attrs)
# choices can be any iterable, but we may need to render this widget
# multiple times. Thus, collapse it into a list so it can be consumed
# more than once.
self.choices = list(choices)
self.choices = choices

def __deepcopy__(self, memo):
obj = copy.copy(self)
Expand Down Expand Up @@ -741,6 +739,14 @@ def format_value(self, value):
value = [value]
return [str(v) if v is not None else "" for v in value]

@property
def choices(self):
return self._choices

@choices.setter
def choices(self, value):
self._choices = normalize_choices(value)


class Select(ChoiceWidget):
input_type = "select"
Expand Down
63 changes: 63 additions & 0 deletions django/utils/choices.py
@@ -0,0 +1,63 @@
from collections.abc import Callable, Iterable, Iterator, Mapping

from django.db.models.enums import ChoicesMeta
from django.utils.functional import Promise


class ChoiceIterator:
"""Base class for lazy iterators for choices."""


class CallableChoiceIterator(ChoiceIterator):
"""Iterator to lazily normalize choices generated by a callable."""

def __init__(self, func):
self.func = func

def __iter__(self):
yield from normalize_choices(self.func())


def normalize_choices(value, *, depth=0):
"""Normalize choices values consistently for fields and widgets."""

match value:
case ChoiceIterator() | Promise() | bytes() | str():
# Avoid prematurely normalizing iterators that should be lazy.
# Because string-like types are iterable, return early to avoid
# iterating over them in the guard for the Iterable case below.
return value
case ChoicesMeta():
# Choices enumeration helpers already output in canonical form.
return value.choices
case Mapping() if depth < 2:
value = value.items()
case Iterator() if depth < 2:
# Although Iterator would be handled by the Iterable case below,
# the iterator would be consumed prematurely while checking that
# its elements are not string-like in the guard, so we handle it
# separately.
pass
case Iterable() if depth < 2 and not any(
isinstance(x, (Promise, bytes, str)) for x in value
):
# String-like types are iterable, so the guard above ensures that
# they're handled by the default case below.
pass
case Callable() if depth == 0:
# If at the top level, wrap callables to be evaluated lazily.
return CallableChoiceIterator(value)
case Callable() if depth < 2:
value = value()
case _:
return value

try:
# Recursive call to convert any nested values to a list of 2-tuples.
return [(k, normalize_choices(v, depth=depth + 1)) for k, v in value]
except (TypeError, ValueError):
# Return original value for the system check to raise if it has items
# that are not iterable or not 2-tuples:
# - TypeError: cannot unpack non-iterable <type> object
# - ValueError: <not enough / too many> values to unpack
return value
17 changes: 12 additions & 5 deletions docs/internals/contributing/writing-code/coding-style.txt
Expand Up @@ -298,16 +298,23 @@ Model style
* Any custom methods

* If ``choices`` is defined for a given model field, define each choice as a
list of tuples, with an all-uppercase name as a class attribute on the model.
mapping, with an all-uppercase name as a class attribute on the model.
Example::

class MyModel(models.Model):
DIRECTION_UP = "U"
DIRECTION_DOWN = "D"
DIRECTION_CHOICES = [
(DIRECTION_UP, "Up"),
(DIRECTION_DOWN, "Down"),
]
DIRECTION_CHOICES = {
DIRECTION_UP: "Up",
DIRECTION_DOWN: "Down",
}

Alternatively, consider using :ref:`field-choices-enum-types`::

class MyModel(models.Model):
class Direction(models.TextChoices):
UP = U, "Up"
DOWN = D, "Down"

Use of ``django.conf.settings``
===============================
Expand Down
8 changes: 5 additions & 3 deletions docs/ref/checks.txt
Expand Up @@ -165,9 +165,11 @@ Model fields
* **fields.E002**: Field names must not contain ``"__"``.
* **fields.E003**: ``pk`` is a reserved word that cannot be used as a field
name.
* **fields.E004**: ``choices`` must be an iterable (e.g., a list or tuple).
* **fields.E005**: ``choices`` must be an iterable containing ``(actual value,
human readable name)`` tuples.
* **fields.E004**: ``choices`` must be a mapping (e.g. a dictionary) or an
iterable (e.g. a list or tuple).
* **fields.E005**: ``choices`` must be a mapping of actual values to human
readable names or an iterable containing ``(actual value, human readable
name)`` tuples.
* **fields.E006**: ``db_index`` must be ``None``, ``True`` or ``False``.
* **fields.E007**: Primary keys must not have ``null=True``.
* **fields.E008**: All ``validators`` must be callable.
Expand Down
10 changes: 5 additions & 5 deletions docs/ref/contrib/admin/actions.txt
Expand Up @@ -47,11 +47,11 @@ news application with an ``Article`` model::

from django.db import models

STATUS_CHOICES = [
("d", "Draft"),
("p", "Published"),
("w", "Withdrawn"),
]
STATUS_CHOICES = {
"d": "Draft",
"p": "Published",
"w": "Withdrawn",
}


class Article(models.Model):
Expand Down
5 changes: 3 additions & 2 deletions docs/ref/forms/fields.txt
Expand Up @@ -510,8 +510,9 @@ For each field, we describe the default widget used if you don't specify

.. versionchanged:: 5.0

Support for using :ref:`enumeration types <field-choices-enum-types>`
directly in the ``choices`` was added.
Support for mappings and using
:ref:`enumeration types <field-choices-enum-types>` directly in
``choices`` was added.

``DateField``
-------------
Expand Down

0 comments on commit 500e010

Please sign in to comment.