apiselect.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import json
  2. from django import forms
  3. from django.conf import settings
  4. from django.forms.models import ModelChoiceIteratorValue
  5. from django.utils.translation import gettext_lazy as _
  6. __all__ = (
  7. 'APISelect',
  8. 'APISelectMultiple',
  9. )
  10. class APISelect(forms.Select):
  11. """
  12. A select widget populated via an API call
  13. :param api_url: API endpoint URL. Required if not set automatically by the parent field.
  14. """
  15. template_name = 'widgets/apiselect.html'
  16. option_template_name = 'widgets/select_option.html'
  17. dynamic_params: dict[str, str]
  18. static_params: dict[str, list[str]]
  19. def get_context(self, name, value, attrs):
  20. context = super().get_context(name, value, attrs)
  21. # Add quick-add context data, if enabled for the widget
  22. if hasattr(self, 'quick_add_context'):
  23. context['quick_add'] = self.quick_add_context
  24. # Read from attrs: __deepcopy__ resets static_params but leaves the serialized attribute intact
  25. raw = self.attrs.get('data-static-params', '[]')
  26. parsed = json.loads(raw)
  27. context['static_params'] = {item['queryParam']: item['queryValue'] for item in parsed}
  28. return context
  29. def __init__(self, api_url=None, full=False, *args, **kwargs):
  30. super().__init__(*args, **kwargs)
  31. self.attrs['class'] = 'api-select'
  32. self.dynamic_params: dict[str, list[str]] = {}
  33. self.static_params: dict[str, list[str]] = {}
  34. if api_url:
  35. self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  36. def __deepcopy__(self, memo):
  37. """Reset `static_params` and `dynamic_params` when APISelect is deepcopied."""
  38. result = super().__deepcopy__(memo)
  39. result.dynamic_params = {}
  40. result.static_params = {}
  41. return result
  42. def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
  43. """Name each model option's parent object, which options loaded from the REST API carry already."""
  44. option = super().create_option(name, value, label, selected, index, subindex, attrs)
  45. parent_field = self.attrs.get('ts-parent-field')
  46. if parent_field and isinstance(value, ModelChoiceIteratorValue):
  47. # The context names an API field, which need not be a model attribute
  48. parent = getattr(value.instance, parent_field, None)
  49. if parent is not None:
  50. option['attrs']['data-parent'] = str(parent)
  51. return option
  52. def _process_query_param(self, key, value) -> None:
  53. """
  54. Based on query param value's type and value, update instance's dynamic/static params.
  55. """
  56. if isinstance(value, str):
  57. # Coerce `True` boolean.
  58. if value.lower() == 'true':
  59. value = True
  60. # Coerce `False` boolean.
  61. elif value.lower() == 'false':
  62. value = False
  63. # Query parameters cannot have a `None` (or `null` in JSON) type, convert
  64. # `None` types to `'null'` so that ?key=null is used in the query URL.
  65. elif value is None:
  66. value = 'null'
  67. # Check type of `value` again, since it may have changed.
  68. if isinstance(value, str):
  69. if value.startswith('$'):
  70. # A value starting with `$` indicates a dynamic query param, where the
  71. # initial value is unknown and will be updated at the JavaScript layer
  72. # as the related form field's value changes.
  73. field_name = value.strip('$')
  74. self.dynamic_params[field_name] = key
  75. else:
  76. # A value _not_ starting with `$` indicates a static query param, where
  77. # the value is already known and should not be changed at the JavaScript
  78. # layer.
  79. if key in self.static_params:
  80. current = self.static_params[key]
  81. self.static_params[key] = [v for v in set([*current, value])]
  82. else:
  83. self.static_params[key] = [value]
  84. else:
  85. # Any non-string values are passed through as static query params, since
  86. # dynamic query param values have to be a string (in order to start with
  87. # `$`).
  88. if key in self.static_params:
  89. current = self.static_params[key]
  90. self.static_params[key] = [v for v in set([*current, value])]
  91. else:
  92. self.static_params[key] = [value]
  93. def _process_query_params(self, query_params):
  94. """
  95. Process an entire query_params dictionary, and handle primitive or list values.
  96. """
  97. for key, value in query_params.items():
  98. if isinstance(value, (list, tuple)):
  99. # If value is a list/tuple, iterate through each item.
  100. for item in value:
  101. self._process_query_param(key, item)
  102. else:
  103. self._process_query_param(key, value)
  104. def _serialize_params(self, key, params):
  105. """
  106. Serialize dynamic or static query params to JSON and add the serialized value to
  107. the widget attributes by `key`.
  108. """
  109. # Deserialize the current serialized value from the widget, using an empty JSON
  110. # array as a fallback in the event one is not defined.
  111. current = json.loads(self.attrs.get(key, '[]'))
  112. # Combine the current values with the updated values and serialize the result as
  113. # JSON. Note: the `separators` kwarg effectively removes extra whitespace from
  114. # the serialized JSON string, which is ideal since these will be passed as
  115. # attributes to HTML elements and parsed on the client.
  116. self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
  117. def _add_dynamic_params(self):
  118. """
  119. Convert post-processed dynamic query params to data structure expected by front-
  120. end, serialize the value to JSON, and add it to the widget attributes.
  121. """
  122. key = 'data-dynamic-params'
  123. if len(self.dynamic_params) > 0:
  124. try:
  125. update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
  126. self._serialize_params(key, update)
  127. except IndexError as error:
  128. raise RuntimeError(
  129. _("Missing required value for dynamic query param: '{dynamic_params}'").format(
  130. dynamic_params=self.dynamic_params
  131. )
  132. ) from error
  133. def _add_static_params(self):
  134. """
  135. Convert post-processed static query params to data structure expected by front-
  136. end, serialize the value to JSON, and add it to the widget attributes.
  137. """
  138. key = 'data-static-params'
  139. if len(self.static_params) > 0:
  140. try:
  141. update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
  142. self._serialize_params(key, update)
  143. except IndexError as error:
  144. raise RuntimeError(
  145. _("Missing required value for static query param: '{static_params}'").format(
  146. static_params=self.static_params
  147. )
  148. ) from error
  149. def add_query_params(self, query_params):
  150. """
  151. Proccess & add a dictionary of URL query parameters to the widget attributes.
  152. """
  153. # Process query parameters. This populates `self.dynamic_params` and `self.static_params`.
  154. self._process_query_params(query_params)
  155. # Add processed dynamic parameters to widget attributes.
  156. self._add_dynamic_params()
  157. # Add processed static parameters to widget attributes.
  158. self._add_static_params()
  159. def add_query_param(self, key, value) -> None:
  160. """
  161. Process & add a key/value pair of URL query parameters to the widget attributes.
  162. """
  163. self.add_query_params({key: value})
  164. class APISelectMultiple(APISelect, forms.SelectMultiple):
  165. pass