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

1from django import forms 

2 

3from utilities.choices import Choice 

4from utilities.forms import widgets 

5 

6__all__ = ( 

7 'ChoiceField', 

8 'MultipleChoiceField', 

9 'TypedChoiceField', 

10) 

11 

12 

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. 

17 

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 

36 

37 

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 

50 

51 

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) 

60 

61 def _get_choices(self): 

62 return _parent_choices_property(type(self)).fget(self) 

63 

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

72 

73 choices = property(_get_choices, _set_choices) 

74 

75 

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 

81 

82 

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 

91 

92 def __init__(self, *, empty_value=None, **kwargs): 

93 super().__init__(empty_value=empty_value, **kwargs) 

94 

95 

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