dynamic.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import django_filters
  2. from django import forms
  3. from django.conf import settings
  4. from django.forms import BoundField
  5. from django.urls import reverse
  6. from utilities.forms import widgets
  7. from utilities.views import get_viewname
  8. __all__ = (
  9. 'DynamicChoiceField',
  10. 'DynamicModelChoiceField',
  11. 'DynamicModelMultipleChoiceField',
  12. 'DynamicMultipleChoiceField',
  13. )
  14. #
  15. # Choice fields
  16. #
  17. class DynamicChoiceField(forms.ChoiceField):
  18. def get_bound_field(self, form, field_name):
  19. bound_field = BoundField(form, self, field_name)
  20. data = bound_field.value()
  21. if data is not None:
  22. self.choices = [
  23. choice for choice in self.choices if choice[0] == data
  24. ]
  25. else:
  26. self.choices = []
  27. return bound_field
  28. class DynamicMultipleChoiceField(forms.MultipleChoiceField):
  29. def get_bound_field(self, form, field_name):
  30. bound_field = BoundField(form, self, field_name)
  31. data = bound_field.value()
  32. if data is not None:
  33. self.choices = [
  34. choice for choice in self.choices if choice[0] and choice[0] in data
  35. ]
  36. return bound_field
  37. #
  38. # Model choice fields
  39. #
  40. class DynamicModelChoiceMixin:
  41. """
  42. Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be
  43. rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
  44. Attributes:
  45. query_params: A dictionary of additional key/value pairs to attach to the API request
  46. initial_params: A dictionary of child field references to use for selecting a parent field's initial value
  47. null_option: The string used to represent a null selection (if any)
  48. disabled_indicator: The name of the field which, if populated, will disable selection of the
  49. choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
  50. context: A mapping of <option> template variables to their API data keys (optional; see below)
  51. selector: Include an advanced object selection widget to assist the user in identifying the desired object
  52. quick_add: Include a widget to quickly create a new related object for assignment. NOTE: Nested usage of
  53. quick-add fields is not currently supported.
  54. quick_add_params: A dictionary of initial data to include when launching the quick-add form (optional). The
  55. token string "$pk" will be replaced with the primary key of the form's instance, if any.
  56. Context keys:
  57. value: The name of the attribute which contains the option's value (default: 'id')
  58. label: The name of the attribute used as the option's human-friendly label (default: 'display')
  59. description: The name of the attribute to use as a description (default: 'description')
  60. depth: The name of the attribute which indicates an object's depth within a recursive hierarchy; must be a
  61. positive integer (default: '_depth')
  62. disabled: The name of the attribute which, if true, signifies that the option should be disabled
  63. parent: The name of the attribute which represents the object's parent object (e.g. device for an interface)
  64. count: The name of the attribute which contains a numeric count of related objects
  65. """
  66. filter = django_filters.ModelChoiceFilter
  67. widget = widgets.APISelect
  68. def __init__(
  69. self,
  70. queryset,
  71. *,
  72. query_params=None,
  73. initial_params=None,
  74. null_option=None,
  75. disabled_indicator=None,
  76. context=None,
  77. selector=False,
  78. quick_add=False,
  79. quick_add_params=None,
  80. **kwargs
  81. ):
  82. self.model = queryset.model
  83. self.query_params = query_params or {}
  84. self.initial_params = initial_params or {}
  85. self.null_option = null_option
  86. self.disabled_indicator = disabled_indicator
  87. self.context = context or {}
  88. self.selector = selector
  89. self.quick_add = quick_add
  90. self.quick_add_params = quick_add_params or {}
  91. super().__init__(queryset, **kwargs)
  92. def widget_attrs(self, widget):
  93. attrs = {}
  94. # Set the string used to represent a null option
  95. if self.null_option is not None:
  96. attrs['data-null-option'] = self.null_option
  97. # Set any custom template attributes for TomSelect
  98. for var, accessor in self.context.items():
  99. attrs[f'ts-{var}-field'] = accessor
  100. # Attach any static query parameters
  101. if len(self.query_params) > 0:
  102. widget.add_query_params(self.query_params)
  103. # Include object selector?
  104. if self.selector:
  105. attrs['selector'] = self.model._meta.label_lower
  106. return attrs
  107. def get_bound_field(self, form, field_name):
  108. bound_field = BoundField(form, self, field_name)
  109. widget = bound_field.field.widget
  110. # Set initial value based on prescribed child fields (if not already set)
  111. if not self.initial and self.initial_params:
  112. filter_kwargs = {}
  113. for kwarg, child_field in self.initial_params.items():
  114. value = form.initial.get(child_field.lstrip('$'))
  115. if value:
  116. filter_kwargs[kwarg] = value
  117. if filter_kwargs:
  118. self.initial = self.queryset.filter(**filter_kwargs).first()
  119. # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
  120. # will be populated on-demand via the APISelect widget.
  121. data = bound_field.value()
  122. if data:
  123. # When the field is multiple choice pass the data as a list if it's not already
  124. if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and type(data) is not list:
  125. data = [data]
  126. field_name = getattr(self, 'to_field_name') or 'pk'
  127. filter = self.filter(field_name=field_name)
  128. try:
  129. self.queryset = filter.filter(self.queryset, data)
  130. except (TypeError, ValueError):
  131. # Catch any error caused by invalid initial data passed from the user
  132. self.queryset = self.queryset.none()
  133. else:
  134. self.queryset = self.queryset.none()
  135. # Normalize the widget choices to a list to accommodate the "null" option, if set
  136. if self.null_option:
  137. widget.choices = [
  138. (settings.FILTERS_NULL_CHOICE_VALUE, self.null_option),
  139. *[c for c in widget.choices]
  140. ]
  141. # Set the data URL on the APISelect widget (if not already set)
  142. if not widget.attrs.get('data-url'):
  143. viewname = get_viewname(self.queryset.model, action='list', rest_api=True)
  144. widget.attrs['data-url'] = reverse(viewname)
  145. # Include quick add?
  146. if self.quick_add:
  147. widget.quick_add_context = {
  148. 'url': reverse(get_viewname(self.model, 'add')),
  149. 'params': {},
  150. }
  151. for k, v in self.quick_add_params.items():
  152. if v == '$pk':
  153. # Replace "$pk" token with the primary key of the form's instance (if any)
  154. if getattr(form.instance, 'pk', None):
  155. widget.quick_add_context['params'][k] = form.instance.pk
  156. else:
  157. widget.quick_add_context['params'][k] = v
  158. return bound_field
  159. class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
  160. """
  161. Dynamic selection field for a single object, backed by NetBox's REST API.
  162. """
  163. def clean(self, value):
  164. """
  165. When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  166. string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  167. """
  168. if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
  169. return None
  170. return super().clean(value)
  171. class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
  172. """
  173. A multiple-choice version of `DynamicModelChoiceField`.
  174. """
  175. filter = django_filters.ModelMultipleChoiceFilter
  176. widget = widgets.APISelectMultiple
  177. def clean(self, value):
  178. value = value or []
  179. # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  180. # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  181. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
  182. value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
  183. return [None, *super().clean(value)]
  184. return super().clean(value)