dynamic.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  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. """
  25. filter = django_filters.ModelChoiceFilter
  26. widget = widgets.APISelect
  27. def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
  28. fetch_trigger=None, empty_label=None, *args, **kwargs):
  29. self.query_params = query_params or {}
  30. self.initial_params = initial_params or {}
  31. self.null_option = null_option
  32. self.disabled_indicator = disabled_indicator
  33. self.fetch_trigger = fetch_trigger
  34. # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
  35. # by widget_attrs()
  36. self.to_field_name = kwargs.get('to_field_name')
  37. self.empty_option = empty_label or ""
  38. super().__init__(*args, **kwargs)
  39. def widget_attrs(self, widget):
  40. attrs = {
  41. 'data-empty-option': self.empty_option
  42. }
  43. # Set value-field attribute if the field specifies to_field_name
  44. if self.to_field_name:
  45. attrs['value-field'] = self.to_field_name
  46. # Set the string used to represent a null option
  47. if self.null_option is not None:
  48. attrs['data-null-option'] = self.null_option
  49. # Set the disabled indicator, if any
  50. if self.disabled_indicator is not None:
  51. attrs['disabled-indicator'] = self.disabled_indicator
  52. # Set the fetch trigger, if any.
  53. if self.fetch_trigger is not None:
  54. attrs['data-fetch-trigger'] = self.fetch_trigger
  55. # Attach any static query parameters
  56. if (len(self.query_params) > 0):
  57. widget.add_query_params(self.query_params)
  58. return attrs
  59. def get_bound_field(self, form, field_name):
  60. bound_field = BoundField(form, self, field_name)
  61. # Set initial value based on prescribed child fields (if not already set)
  62. if not self.initial and self.initial_params:
  63. filter_kwargs = {}
  64. for kwarg, child_field in self.initial_params.items():
  65. value = form.initial.get(child_field.lstrip('$'))
  66. if value:
  67. filter_kwargs[kwarg] = value
  68. if filter_kwargs:
  69. self.initial = self.queryset.filter(**filter_kwargs).first()
  70. # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
  71. # will be populated on-demand via the APISelect widget.
  72. data = bound_field.value()
  73. if data:
  74. # When the field is multiple choice pass the data as a list if it's not already
  75. if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
  76. data = [data]
  77. field_name = getattr(self, 'to_field_name') or 'pk'
  78. filter = self.filter(field_name=field_name)
  79. try:
  80. self.queryset = filter.filter(self.queryset, data)
  81. except (TypeError, ValueError):
  82. # Catch any error caused by invalid initial data passed from the user
  83. self.queryset = self.queryset.none()
  84. else:
  85. self.queryset = self.queryset.none()
  86. # Set the data URL on the APISelect widget (if not already set)
  87. widget = bound_field.field.widget
  88. if not widget.attrs.get('data-url'):
  89. viewname = get_viewname(self.queryset.model, action='list', rest_api=True)
  90. widget.attrs['data-url'] = reverse(viewname)
  91. return bound_field
  92. class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
  93. """
  94. Dynamic selection field for a single object, backed by NetBox's REST API.
  95. """
  96. def clean(self, value):
  97. """
  98. When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  99. string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  100. """
  101. if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
  102. return None
  103. return super().clean(value)
  104. class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
  105. """
  106. A multiple-choice version of `DynamicModelChoiceField`.
  107. """
  108. filter = django_filters.ModelMultipleChoiceFilter
  109. widget = widgets.APISelectMultiple
  110. def clean(self, value):
  111. """
  112. When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
  113. string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
  114. """
  115. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
  116. value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
  117. return [None, *value]
  118. return super().clean(value)