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
« 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
6from utilities.forms import widgets
7from utilities.views import get_action_url
9__all__ = (
10 'DynamicChoiceField',
11 'DynamicModelChoiceField',
12 'DynamicModelMultipleChoiceField',
13 'DynamicMultipleChoiceField',
14)
17#
18# Choice fields
19#
21class DynamicChoiceField(forms.ChoiceField):
23 def get_bound_field(self, form, field_name):
24 bound_field = BoundField(form, self, field_name)
25 data = bound_field.value()
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 = []
34 return bound_field
37class DynamicMultipleChoiceField(forms.MultipleChoiceField):
39 def get_bound_field(self, form, field_name):
40 bound_field = BoundField(form, self, field_name)
41 data = bound_field.value()
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 = []
50 return bound_field
53#
54# Model choice fields
55#
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.
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.
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
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 {}
112 super().__init__(queryset, **kwargs)
114 def widget_attrs(self, widget):
115 attrs = {}
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
121 # Set any custom template attributes for TomSelect
122 for var, accessor in self.context.items():
123 attrs[f'ts-{var}-field'] = accessor
125 # Attach any static query parameters
126 if len(self.query_params) > 0:
127 widget.add_query_params(self.query_params)
129 # Include object selector?
130 if self.selector:
131 attrs['selector'] = self.model._meta.label_lower
133 return attrs
135 def get_bound_field(self, form, field_name):
136 bound_field = BoundField(form, self, field_name)
137 widget = bound_field.field.widget
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()
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()
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]
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()
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 ]
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)
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
193 return bound_field
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)
210class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
211 """
212 A multiple-choice version of `DynamicModelChoiceField`.
213 """
214 filter = django_filters.ModelMultipleChoiceFilter
215 widget = widgets.APISelectMultiple
217 def clean(self, value):
218 value = value or []
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)]
226 return super().clean(value)