choices.py 4.3 KB

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