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

1from django import forms 

2 

3from netbox.choices import ColorChoices 

4 

5from ..utils import add_blank_choice 

6 

7__all__ = ( 

8 'BulkEditNullBooleanSelect', 

9 'ClearableSelect', 

10 'ColorSelect', 

11 'HTMXSelect', 

12 'Select', 

13 'SelectMultiple', 

14 'SelectWithPK', 

15 'SplitMultiSelectWidget', 

16) 

17 

18 

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 {} 

28 

29 def __deepcopy__(self, memo): 

30 obj = super().__deepcopy__(memo) 

31 obj.descriptions = self.descriptions.copy() 

32 return obj 

33 

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) 

36 

37 if description := self.descriptions.get(value, ''): 

38 option['attrs']['data-description'] = description 

39 

40 return option 

41 

42 

43class Select(AttrSelectMixin, forms.Select): 

44 """ 

45 A Select widget which renders an optional description beneath each option's label. 

46 """ 

47 

48 

49class SelectMultiple(AttrSelectMixin, forms.SelectMultiple): 

50 """ 

51 A SelectMultiple widget which renders an optional description beneath each option's label. 

52 """ 

53 

54 

55class BulkEditNullBooleanSelect(forms.NullBooleanSelect): 

56 """ 

57 A Select widget for NullBooleanFields 

58 """ 

59 

60 def __init__(self, *args, **kwargs): 

61 super().__init__(*args, **kwargs) 

62 

63 # Override the built-in choice labels 

64 self.choices = ( 

65 ('1', '---------'), 

66 ('2', 'Yes'), 

67 ('3', 'No'), 

68 ) 

69 

70 

71class ClearableSelect(forms.Select): 

72 """ 

73 A Select widget that will be automatically cleared when one or more required fields are cleared. 

74 

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 """ 

79 

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) 

84 

85 

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' 

91 

92 def __init__(self, *args, **kwargs): 

93 kwargs['choices'] = add_blank_choice(ColorChoices) 

94 super().__init__(*args, **kwargs) 

95 self.attrs['class'] = 'color-select' 

96 

97 

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) 

117 

118 super().__init__(attrs=_attrs, **kwargs) 

119 

120 

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' 

126 

127 

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 

134 

135 def optgroups(self, name, value, attrs=None): 

136 filtered_choices = [] 

137 include_selected = self.include_selected 

138 

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 ] 

145 

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) 

151 

152 self.choices = filtered_choices 

153 value = [] # Clear selected choices 

154 return super().optgroups(name, value, attrs) 

155 

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 

160 

161 

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 """ 

167 

168 def get_context(self, name, value, attrs): 

169 context = super().get_context(name, value, attrs) 

170 

171 # This widget should never require a selection 

172 context['widget']['attrs']['required'] = False 

173 

174 return context 

175 

176 

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 

183 

184 

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. 

189 

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 

197 

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 ] 

209 

210 super().__init__(widgets, attrs) 

211 

212 self.ordering = ordering 

213 

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] 

218 

219 # Include ordering boolean in widget context 

220 context = super().get_context(name, value, attrs) 

221 context['widget']['ordering'] = self.ordering 

222 return context 

223 

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]