dynamic.py 9.1 KB

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