Coverage for utilities/forms/fields/choices.py: 93%
43 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django import forms
3from utilities.choices import Choice
4from utilities.forms import widgets
6__all__ = (
7 'ChoiceField',
8 'MultipleChoiceField',
9 'TypedChoiceField',
10)
13def _map_choice_attr(choices, attr):
14 """
15 Build a {value: attr_value} mapping from the Choice objects among an iterable of choices (descending into
16 optgroups), for the given Choice attribute (e.g. 'description' or 'color'). Values of None are omitted.
18 Django flattens choices to plain (value, label) tuples before they reach the widget, so this is collected from
19 the field's original choices, where the Choice objects are still intact. A ChoiceSet is iterable (yielding its
20 Choice objects); a plain callable (lazy choices) is not, and yields an empty mapping.
21 """
22 mapping = {}
23 try:
24 entries = iter(choices)
25 except TypeError:
26 return mapping
27 for choice in entries:
28 # Descend into an optgroup's members; a Choice is always a flat choice
29 members = [choice]
30 if not isinstance(choice, Choice) and isinstance(choice[1], (list, tuple)):
31 members = choice[1]
32 for member in members:
33 if isinstance(member, Choice) and (value := getattr(member, attr)) is not None:
34 mapping[member.value] = value
35 return mapping
38def _parent_choices_property(cls):
39 """
40 Return the `choices` property inherited from the first class after AttrChoiceMixin in cls's MRO (i.e. the
41 Django field's own property). Used to delegate to the parent getter/setter without hardcoding a specific base
42 class, so a setter override on a future Django subclass (e.g. TypedChoiceField/MultipleChoiceField) isn't
43 bypassed.
44 """
45 mro = cls.__mro__
46 for klass in mro[mro.index(AttrChoiceMixin) + 1:]: 46 ↛ 49line 46 didn't jump to line 49 because the loop on line 46 didn't complete
47 if isinstance(prop := klass.__dict__.get('choices'), property):
48 return prop
49 raise AttributeError(f"No parent 'choices' property found for {cls.__name__}") # pragma: no cover
52class AttrChoiceMixin:
53 """
54 Reads option descriptions from the Choice objects among a field's choices and passes them to a
55 description-aware Select widget for rendering as option subtitles. Set `show_descriptions=False` to suppress.
56 """
57 def __init__(self, *, choices=(), show_descriptions=True, **kwargs):
58 self.show_descriptions = show_descriptions
59 super().__init__(choices=choices, **kwargs)
61 def _get_choices(self):
62 return _parent_choices_property(type(self)).fget(self)
64 def _set_choices(self, value):
65 # Delegate to the parent setter (updates self._choices and self.widget.choices), then refresh the widget's
66 # description map from the same choices. Collecting descriptions here (rather than once in __init__) keeps
67 # them in sync should the field's choices be reassigned after construction. Descriptions are read from the
68 # raw choices, where the Choice objects are still intact, before Django normalizes them to (value, label).
69 _parent_choices_property(type(self)).fset(self, value)
70 if getattr(self, 'show_descriptions', True): 70 ↛ exitline 70 didn't return from function '_set_choices' because the condition on line 70 was always true
71 self.widget.descriptions = _map_choice_attr(value, 'description')
73 choices = property(_get_choices, _set_choices)
76class ChoiceField(AttrChoiceMixin, forms.ChoiceField):
77 """
78 Extends Django's ChoiceField to render the description defined on each Choice as an option subtitle.
79 """
80 widget = widgets.Select
83class TypedChoiceField(AttrChoiceMixin, forms.TypedChoiceField):
84 """
85 A description-aware ChoiceField for use on nullable model choice fields. Like Django's TypedChoiceField, an empty
86 selection is coerced to `empty_value` (which defaults to None here) so that a blank submission is stored as NULL
87 rather than an empty string. This mirrors the form field Django generates automatically for a nullable choice
88 field, while also rendering the description defined on each Choice as an option subtitle.
89 """
90 widget = widgets.Select
92 def __init__(self, *, empty_value=None, **kwargs):
93 super().__init__(empty_value=empty_value, **kwargs)
96class MultipleChoiceField(AttrChoiceMixin, forms.MultipleChoiceField):
97 """
98 Extends Django's MultipleChoiceField to render the description defined on each Choice as an option subtitle.
99 """
100 widget = widgets.SelectMultiple