Coverage for utilities/forms/fields/dynamic.py: 38%

96 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import django_filters 

2from django import forms 

3from django.conf import settings 

4from django.forms import BoundField 

5 

6from utilities.forms import widgets 

7from utilities.views import get_action_url 

8 

9__all__ = ( 

10 'DynamicChoiceField', 

11 'DynamicModelChoiceField', 

12 'DynamicModelMultipleChoiceField', 

13 'DynamicMultipleChoiceField', 

14) 

15 

16 

17# 

18# Choice fields 

19# 

20 

21class DynamicChoiceField(forms.ChoiceField): 

22 

23 def get_bound_field(self, form, field_name): 

24 bound_field = BoundField(form, self, field_name) 

25 data = bound_field.value() 

26 

27 if data is not None: 

28 self.choices = [ 

29 choice for choice in self.choices if choice[0] == data 

30 ] 

31 else: 

32 self.choices = [] 

33 

34 return bound_field 

35 

36 

37class DynamicMultipleChoiceField(forms.MultipleChoiceField): 

38 

39 def get_bound_field(self, form, field_name): 

40 bound_field = BoundField(form, self, field_name) 

41 data = bound_field.value() 

42 

43 if data is not None: 

44 self.choices = [ 

45 choice for choice in self.choices if choice[0] and choice[0] in data 

46 ] 

47 else: 

48 self.choices = [] 

49 

50 return bound_field 

51 

52 

53# 

54# Model choice fields 

55# 

56 

57class DynamicModelChoiceMixin: 

58 """ 

59 Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be 

60 rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. 

61 

62 Attributes: 

63 query_params: A dictionary of additional key/value pairs to attach to the API request 

64 initial_params: A dictionary of child field references to use for selecting a parent field's initial value 

65 null_option: The string used to represent a null selection (if any) 

66 disabled_indicator: The name of the field which, if populated, will disable selection of the 

67 choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead) 

68 context: A mapping of <option> template variables to their API data keys (optional; see below) 

69 selector: Include an advanced object selection widget to assist the user in identifying the desired object 

70 quick_add: Include a widget to quickly create a new related object for assignment. NOTE: Nested usage of 

71 quick-add fields is not currently supported. 

72 quick_add_params: A dictionary of initial data to include when launching the quick-add form (optional). The 

73 token string "$pk" will be replaced with the primary key of the form's instance, if any. 

74 

75 Context keys: 

76 value: The name of the attribute which contains the option's value (default: 'id') 

77 label: The name of the attribute used as the option's human-friendly label (default: 'display') 

78 description: The name of the attribute to use as a description (default: 'description') 

79 depth: The name of the attribute which indicates an object's depth within a recursive hierarchy; must be a 

80 positive integer (default: '_depth') 

81 disabled: The name of the attribute which, if true, signifies that the option should be disabled 

82 parent: The name of the attribute which represents the object's parent object (e.g. device for an interface) 

83 count: The name of the attribute which contains a numeric count of related objects 

84 """ 

85 filter = django_filters.ModelChoiceFilter 

86 widget = widgets.APISelect 

87 

88 def __init__( 

89 self, 

90 queryset, 

91 *, 

92 query_params=None, 

93 initial_params=None, 

94 null_option=None, 

95 disabled_indicator=None, 

96 context=None, 

97 selector=False, 

98 quick_add=False, 

99 quick_add_params=None, 

100 **kwargs 

101 ): 

102 self.model = queryset.model 

103 self.query_params = query_params or {} 

104 self.initial_params = initial_params or {} 

105 self.null_option = null_option 

106 self.disabled_indicator = disabled_indicator 

107 self.context = context or {} 

108 self.selector = selector 

109 self.quick_add = quick_add 

110 self.quick_add_params = quick_add_params or {} 

111 

112 super().__init__(queryset, **kwargs) 

113 

114 def widget_attrs(self, widget): 

115 attrs = {} 

116 

117 # Set the string used to represent a null option 

118 if self.null_option is not None: 

119 attrs['data-null-option'] = self.null_option 

120 

121 # Set any custom template attributes for TomSelect 

122 for var, accessor in self.context.items(): 

123 attrs[f'ts-{var}-field'] = accessor 

124 

125 # Attach any static query parameters 

126 if len(self.query_params) > 0: 

127 widget.add_query_params(self.query_params) 

128 

129 # Include object selector? 

130 if self.selector: 

131 attrs['selector'] = self.model._meta.label_lower 

132 

133 return attrs 

134 

135 def get_bound_field(self, form, field_name): 

136 bound_field = BoundField(form, self, field_name) 

137 widget = bound_field.field.widget 

138 

139 # Set initial value based on prescribed child fields (if not already set) 

140 if not self.initial and self.initial_params: 

141 filter_kwargs = {} 

142 for kwarg, child_field in self.initial_params.items(): 

143 value = form.initial.get(child_field.lstrip('$')) 

144 if value: 

145 filter_kwargs[kwarg] = value 

146 if filter_kwargs: 

147 self.initial = self.queryset.filter(**filter_kwargs).first() 

148 

149 # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options 

150 # will be populated on-demand via the APISelect widget. 

151 data = bound_field.value() 

152 

153 if data: 

154 # When the field is multiple choice pass the data as a list if it's not already 

155 if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and type(data) is not list: 

156 data = [data] 

157 

158 field_name = getattr(self, 'to_field_name') or 'pk' 

159 filter = self.filter(field_name=field_name) 

160 try: 

161 self.queryset = filter.filter(self.queryset, data) 

162 except (TypeError, ValueError): 

163 # Catch any error caused by invalid initial data passed from the user 

164 self.queryset = self.queryset.none() 

165 else: 

166 self.queryset = self.queryset.none() 

167 

168 # Normalize the widget choices to a list to accommodate the "null" option, if set 

169 if self.null_option: 

170 widget.choices = [ 

171 (settings.FILTERS_NULL_CHOICE_VALUE, self.null_option), 

172 *[c for c in widget.choices] 

173 ] 

174 

175 # Set the data URL on the APISelect widget (if not already set) 

176 if not widget.attrs.get('data-url'): 

177 widget.attrs['data-url'] = get_action_url(self.queryset.model, action='list', rest_api=True) 

178 

179 # Include quick add? 

180 if self.quick_add: 

181 widget.quick_add_context = { 

182 'url': get_action_url(self.model, action='add'), 

183 'params': {}, 

184 } 

185 for k, v in self.quick_add_params.items(): 

186 if v == '$pk': 

187 # Replace "$pk" token with the primary key of the form's instance (if any) 

188 if getattr(form.instance, 'pk', None): 

189 widget.quick_add_context['params'][k] = form.instance.pk 

190 else: 

191 widget.quick_add_context['params'][k] = v 

192 

193 return bound_field 

194 

195 

196class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): 

197 """ 

198 Dynamic selection field for a single object, backed by NetBox's REST API. 

199 """ 

200 def clean(self, value): 

201 """ 

202 When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the 

203 string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. 

204 """ 

205 if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE: 

206 return None 

207 return super().clean(value) 

208 

209 

210class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): 

211 """ 

212 A multiple-choice version of `DynamicModelChoiceField`. 

213 """ 

214 filter = django_filters.ModelMultipleChoiceFilter 

215 widget = widgets.APISelectMultiple 

216 

217 def clean(self, value): 

218 value = value or [] 

219 

220 # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the 

221 # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. 

222 if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: 

223 value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] 

224 return [None, *super().clean(value)] 

225 

226 return super().clean(value)