dynamic.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  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. Context keys:
  53. value: The name of the attribute which contains the option's value (default: 'id')
  54. label: The name of the attribute used as the option's human-friendly label (default: 'display')
  55. description: The name of the attribute to use as a description (default: 'description')
  56. depth: The name of the attribute which indicates an object's depth within a recursive hierarchy; must be a
  57. positive integer (default: '_depth')
  58. disabled: The name of the attribute which, if true, signifies that the option should be disabled
  59. parent: The name of the attribute which represents the object's parent object (e.g. device for an interface)
  60. count: The name of the attribute which contains a numeric count of related objects
  61. """
  62. filter = django_filters.ModelChoiceFilter
  63. widget = widgets.APISelect
  64. def __init__(
  65. self,
  66. queryset,
  67. *,
  68. query_params=None,
  69. initial_params=None,
  70. null_option=None,
  71. disabled_indicator=None,
  72. context=None,
  73. selector=False,
  74. **kwargs
  75. ):
  76. self.model = queryset.model
  77. self.query_params = query_params or {}
  78. self.initial_params = initial_params or {}
  79. self.null_option = null_option
  80. self.disabled_indicator = disabled_indicator
  81. self.context = context or {}
  82. self.selector = selector
  83. super().__init__(queryset, **kwargs)
  84. def widget_attrs(self, widget):
  85. attrs = {}
  86. # Set the string used to represent a null option
  87. if self.null_option is not None:
  88. attrs['data-null-option'] = self.null_option
  89. # Set any custom template attributes for TomSelect
  90. for var, accessor in self.context.items():
  91. attrs[f'ts-{var}-field'] = accessor
  92. # Attach any static query parameters
  93. if len(self.query_params) > 0:
  94. widget.add_query_params(self.query_params)
  95. # Include object selector?
  96. if self.selector:
  97. attrs['selector'] = self.model._meta.label_lower
  98. return attrs
  99. def get_bound_field(self, form, field_name):
  100. bound_field = BoundField(form, self, field_name)
  101. # Set initial value based on prescribed child fields (if not already set)
  102. if not self.initial and self.initial_params:
  103. filter_kwargs = {}
  104. for kwarg, child_field in self.initial_params.items():
  105. value = form.initial.get(child_field.lstrip('$'))
  106. if value:
  107. filter_kwargs[kwarg] = value
  108. if filter_kwargs:
  109. self.initial = self.queryset.filter(**filter_kwargs).first()
  110. # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
  111. # will be populated on-demand via the APISelect widget.
  112. data = bound_field.value()
  113. if data:
  114. # When the field is multiple choice pass the data as a list if it's not already
  115. if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
  116. data = [data]
  117. field_name = getattr(self, 'to_field_name') or 'pk'
  118. filter = self.filter(field_name=field_name)
  119. try:
  120. self.queryset = filter.filter(self.queryset, data)
  121. except (TypeError, ValueError):
  122. # Catch any error caused by invalid initial data passed from the user
  123. self.queryset = self.queryset.none()
  124. else:
  125. self.queryset = self.queryset.none()
  126. # Set the data URL on the APISelect widget (if not already set)
  127. widget = bound_field.field.widget
  128. if not widget.attrs.get('data-url'):
  129. viewname = get_viewname(self.queryset.model, action='list', rest_api=True)
  130. widget.attrs['data-url'] = reverse(viewname)
  131. return bound_field
  132. class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
  133. """
  134. Dynamic selection field for a single object, backed by NetBox's REST API.
  135. """
  136. def clean(self, value):
  137. """
  138. When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  139. string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  140. """
  141. if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
  142. return None
  143. return super().clean(value)
  144. class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
  145. """
  146. A multiple-choice version of `DynamicModelChoiceField`.
  147. """
  148. filter = django_filters.ModelMultipleChoiceFilter
  149. widget = widgets.APISelectMultiple
  150. def clean(self, value):
  151. value = value or []
  152. # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  153. # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  154. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
  155. value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
  156. return [None, *super().clean(value)]
  157. return super().clean(value)