Coverage for utilities/forms/widgets/select.py: 62%
90 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 netbox.choices import ColorChoices
5from ..utils import add_blank_choice
7__all__ = (
8 'BulkEditNullBooleanSelect',
9 'ClearableSelect',
10 'ColorSelect',
11 'HTMXSelect',
12 'Select',
13 'SelectMultiple',
14 'SelectWithPK',
15 'SplitMultiSelectWidget',
16)
19class AttrSelectMixin:
20 """
21 Annotates each rendered <option> with a `data-description` attribute, which is displayed as subtitle text
22 beneath the option's label. Descriptions are sourced from an explicit value-to-description mapping set on
23 `descriptions`.
24 """
25 def __init__(self, *args, descriptions=None, **kwargs):
26 super().__init__(*args, **kwargs)
27 self.descriptions = descriptions or {}
29 def __deepcopy__(self, memo):
30 obj = super().__deepcopy__(memo)
31 obj.descriptions = self.descriptions.copy()
32 return obj
34 def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
35 option = super().create_option(name, value, label, selected, index, subindex, attrs)
37 if description := self.descriptions.get(value, ''):
38 option['attrs']['data-description'] = description
40 return option
43class Select(AttrSelectMixin, forms.Select):
44 """
45 A Select widget which renders an optional description beneath each option's label.
46 """
49class SelectMultiple(AttrSelectMixin, forms.SelectMultiple):
50 """
51 A SelectMultiple widget which renders an optional description beneath each option's label.
52 """
55class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
56 """
57 A Select widget for NullBooleanFields
58 """
60 def __init__(self, *args, **kwargs):
61 super().__init__(*args, **kwargs)
63 # Override the built-in choice labels
64 self.choices = (
65 ('1', '---------'),
66 ('2', 'Yes'),
67 ('3', 'No'),
68 )
71class ClearableSelect(forms.Select):
72 """
73 A Select widget that will be automatically cleared when one or more required fields are cleared.
75 Args:
76 requires_fields: A list of field names that this field depends on. When any of these fields
77 are cleared, this field will also be cleared automatically via JavaScript.
78 """
80 def __init__(self, *args, requires_fields=None, **kwargs):
81 super().__init__(*args, **kwargs)
82 if requires_fields: 82 ↛ exitline 82 didn't return from function '__init__' because the condition on line 82 was always true
83 self.attrs['data-requires-fields'] = ','.join(requires_fields)
86class ColorSelect(forms.Select):
87 """
88 Extends the built-in Select widget to colorize each <option>.
89 """
90 option_template_name = 'widgets/colorselect_option.html'
92 def __init__(self, *args, **kwargs):
93 kwargs['choices'] = add_blank_choice(ColorChoices)
94 super().__init__(*args, **kwargs)
95 self.attrs['class'] = 'color-select'
98class HTMXSelect(Select):
99 """
100 Selection widget that re-generates the HTML form upon selection of a new option, and supports
101 per-option descriptions alongside its HTMX behavior.
102 """
103 def __init__(self, method='get', hx_url='.', hx_include_id='form_fields', hx_target_id=None, attrs=None, **kwargs):
104 method = method.lower()
105 if method not in ('delete', 'get', 'patch', 'post', 'put'): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 raise ValueError(f"Unsupported HTTP method: {method}")
107 _attrs = {
108 f'hx-{method}': hx_url,
109 'hx-include': f'#{hx_include_id}',
110 'hx-target': f'#{hx_target_id}' if hx_target_id else f'#{hx_include_id}',
111 }
112 if hx_target_id:
113 _attrs['hx-select'] = f'#{hx_target_id}'
114 _attrs['hx-swap'] = 'outerHTML'
115 if attrs:
116 _attrs.update(attrs)
118 super().__init__(attrs=_attrs, **kwargs)
121class SelectWithPK(forms.Select):
122 """
123 Include the primary key of each option in the option label (e.g. "Router7 (4721)").
124 """
125 option_template_name = 'widgets/select_option_with_pk.html'
128class SelectMultipleBase(forms.SelectMultiple):
129 """
130 Base class for select widgets that filter choices based on selected values.
131 Subclasses should set `include_selected` to control filtering behavior.
132 """
133 include_selected = False
135 def optgroups(self, name, value, attrs=None):
136 filtered_choices = []
137 include_selected = self.include_selected
139 for choice in self.choices:
140 if isinstance(choice[1], (list, tuple)): # optgroup
141 group_label, group_choices = choice
142 filtered_group = [
143 c for c in group_choices if (str(c[0]) in value) == include_selected
144 ]
146 if filtered_group: # Only include optgroup if it has choices left
147 filtered_choices.append((group_label, filtered_group))
148 else: # option, e.g. flat choice
149 if (str(choice[0]) in value) == include_selected:
150 filtered_choices.append(choice)
152 self.choices = filtered_choices
153 value = [] # Clear selected choices
154 return super().optgroups(name, value, attrs)
156 def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
157 option = super().create_option(name, value, label, selected, index, subindex, attrs)
158 option['attrs']['title'] = label # Add title attribute to show full text on hover
159 return option
162class AvailableOptions(SelectMultipleBase):
163 """
164 Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
165 will be empty.) Employed by SplitMultiSelectWidget.
166 """
168 def get_context(self, name, value, attrs):
169 context = super().get_context(name, value, attrs)
171 # This widget should never require a selection
172 context['widget']['attrs']['required'] = False
174 return context
177class SelectedOptions(SelectMultipleBase):
178 """
179 Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
180 will include _all_ choices.) Employed by SplitMultiSelectWidget.
181 """
182 include_selected = True
185class SplitMultiSelectWidget(forms.MultiWidget):
186 """
187 Renders two <select multiple=true> widgets side-by-side: one listing available choices, the other listing selected
188 choices. Options are selected by moving them from the left column to the right.
190 Args:
191 ordering: If true, the selected choices list will include controls to reorder items within the list. This should
192 be enabled only if the order of the selected choices is significant.
193 """
194 template_name = 'widgets/splitmultiselect.html'
195 available_widget_class = AvailableOptions
196 selected_widget_class = SelectedOptions
198 def __init__(self, choices, attrs=None, ordering=False):
199 widgets = [
200 self.available_widget_class(
201 attrs={'size': 8},
202 choices=choices
203 ),
204 self.selected_widget_class(
205 attrs={'size': 8, 'class': 'select-all'},
206 choices=choices
207 ),
208 ]
210 super().__init__(widgets, attrs)
212 self.ordering = ordering
214 def get_context(self, name, value, attrs):
215 # Replicate value for each multi-select widget
216 # Django bug? See django/forms/widgets.py L985
217 value = [value, value]
219 # Include ordering boolean in widget context
220 context = super().get_context(name, value, attrs)
221 context['widget']['ordering'] = self.ordering
222 return context
224 def value_from_datadict(self, data, files, name):
225 # Return only the choices from the SelectedOptions widget
226 return super().value_from_datadict(data, files, name)[1]