dynamic.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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.utils import get_viewname
  8. __all__ = (
  9. 'DynamicModelChoiceField',
  10. 'DynamicModelMultipleChoiceField',
  11. )
  12. class DynamicModelChoiceMixin:
  13. """
  14. Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be
  15. rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
  16. Attributes:
  17. query_params: A dictionary of additional key/value pairs to attach to the API request
  18. initial_params: A dictionary of child field references to use for selecting a parent field's initial value
  19. null_option: The string used to represent a null selection (if any)
  20. disabled_indicator: The name of the field which, if populated, will disable selection of the
  21. choice (optional)
  22. fetch_trigger: The event type which will cause the select element to
  23. fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
  24. selector: Include an advanced object selection widget to assist the user in identifying the desired object
  25. """
  26. filter = django_filters.ModelChoiceFilter
  27. widget = widgets.APISelect
  28. def __init__(
  29. self,
  30. queryset,
  31. *,
  32. query_params=None,
  33. initial_params=None,
  34. null_option=None,
  35. disabled_indicator=None,
  36. fetch_trigger=None,
  37. empty_label=None,
  38. selector=False,
  39. **kwargs
  40. ):
  41. self.model = queryset.model
  42. self.query_params = query_params or {}
  43. self.initial_params = initial_params or {}
  44. self.null_option = null_option
  45. self.disabled_indicator = disabled_indicator
  46. self.fetch_trigger = fetch_trigger
  47. self.selector = selector
  48. # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
  49. # by widget_attrs()
  50. self.to_field_name = kwargs.get('to_field_name')
  51. self.empty_option = empty_label or ""
  52. super().__init__(queryset, **kwargs)
  53. def widget_attrs(self, widget):
  54. attrs = {
  55. 'data-empty-option': self.empty_option
  56. }
  57. # Set value-field attribute if the field specifies to_field_name
  58. if self.to_field_name:
  59. attrs['value-field'] = self.to_field_name
  60. # Set the string used to represent a null option
  61. if self.null_option is not None:
  62. attrs['data-null-option'] = self.null_option
  63. # Set the disabled indicator, if any
  64. if self.disabled_indicator is not None:
  65. attrs['disabled-indicator'] = self.disabled_indicator
  66. # Set the fetch trigger, if any.
  67. if self.fetch_trigger is not None:
  68. attrs['data-fetch-trigger'] = self.fetch_trigger
  69. # Attach any static query parameters
  70. if (len(self.query_params) > 0):
  71. widget.add_query_params(self.query_params)
  72. # Include object selector?
  73. if self.selector:
  74. attrs['selector'] = self.model._meta.label_lower
  75. return attrs
  76. def get_bound_field(self, form, field_name):
  77. bound_field = BoundField(form, self, field_name)
  78. # Set initial value based on prescribed child fields (if not already set)
  79. if not self.initial and self.initial_params:
  80. filter_kwargs = {}
  81. for kwarg, child_field in self.initial_params.items():
  82. value = form.initial.get(child_field.lstrip('$'))
  83. if value:
  84. filter_kwargs[kwarg] = value
  85. if filter_kwargs:
  86. self.initial = self.queryset.filter(**filter_kwargs).first()
  87. # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
  88. # will be populated on-demand via the APISelect widget.
  89. data = bound_field.value()
  90. if data:
  91. # When the field is multiple choice pass the data as a list if it's not already
  92. if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
  93. data = [data]
  94. field_name = getattr(self, 'to_field_name') or 'pk'
  95. filter = self.filter(field_name=field_name)
  96. try:
  97. self.queryset = filter.filter(self.queryset, data)
  98. except (TypeError, ValueError):
  99. # Catch any error caused by invalid initial data passed from the user
  100. self.queryset = self.queryset.none()
  101. else:
  102. self.queryset = self.queryset.none()
  103. # Set the data URL on the APISelect widget (if not already set)
  104. widget = bound_field.field.widget
  105. if not widget.attrs.get('data-url'):
  106. viewname = get_viewname(self.queryset.model, action='list', rest_api=True)
  107. widget.attrs['data-url'] = reverse(viewname)
  108. return bound_field
  109. class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
  110. """
  111. Dynamic selection field for a single object, backed by NetBox's REST API.
  112. """
  113. def clean(self, value):
  114. """
  115. When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  116. string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  117. """
  118. if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
  119. return None
  120. return super().clean(value)
  121. class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
  122. """
  123. A multiple-choice version of `DynamicModelChoiceField`.
  124. """
  125. filter = django_filters.ModelMultipleChoiceFilter
  126. widget = widgets.APISelectMultiple
  127. def clean(self, value):
  128. value = value or []
  129. # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  130. # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  131. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
  132. value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
  133. return [None, *value]
  134. return super().clean(value)