apiselect.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import json
  2. from typing import Dict, List, Tuple
  3. from django import forms
  4. from django.conf import settings
  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. return context
  25. def __init__(self, api_url=None, full=False, *args, **kwargs):
  26. super().__init__(*args, **kwargs)
  27. self.attrs['class'] = 'api-select'
  28. self.dynamic_params: Dict[str, List[str]] = {}
  29. self.static_params: Dict[str, List[str]] = {}
  30. if api_url:
  31. self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  32. def __deepcopy__(self, memo):
  33. """Reset `static_params` and `dynamic_params` when APISelect is deepcopied."""
  34. result = super().__deepcopy__(memo)
  35. result.dynamic_params = {}
  36. result.static_params = {}
  37. return result
  38. def _process_query_param(self, key, value) -> None:
  39. """
  40. Based on query param value's type and value, update instance's dynamic/static params.
  41. """
  42. if isinstance(value, str):
  43. # Coerce `True` boolean.
  44. if value.lower() == 'true':
  45. value = True
  46. # Coerce `False` boolean.
  47. elif value.lower() == 'false':
  48. value = False
  49. # Query parameters cannot have a `None` (or `null` in JSON) type, convert
  50. # `None` types to `'null'` so that ?key=null is used in the query URL.
  51. elif value is None:
  52. value = 'null'
  53. # Check type of `value` again, since it may have changed.
  54. if isinstance(value, str):
  55. if value.startswith('$'):
  56. # A value starting with `$` indicates a dynamic query param, where the
  57. # initial value is unknown and will be updated at the JavaScript layer
  58. # as the related form field's value changes.
  59. field_name = value.strip('$')
  60. self.dynamic_params[field_name] = key
  61. else:
  62. # A value _not_ starting with `$` indicates a static query param, where
  63. # the value is already known and should not be changed at the JavaScript
  64. # layer.
  65. if key in self.static_params:
  66. current = self.static_params[key]
  67. self.static_params[key] = [v for v in set([*current, value])]
  68. else:
  69. self.static_params[key] = [value]
  70. else:
  71. # Any non-string values are passed through as static query params, since
  72. # dynamic query param values have to be a string (in order to start with
  73. # `$`).
  74. if key in self.static_params:
  75. current = self.static_params[key]
  76. self.static_params[key] = [v for v in set([*current, value])]
  77. else:
  78. self.static_params[key] = [value]
  79. def _process_query_params(self, query_params):
  80. """
  81. Process an entire query_params dictionary, and handle primitive or list values.
  82. """
  83. for key, value in query_params.items():
  84. if isinstance(value, (List, Tuple)):
  85. # If value is a list/tuple, iterate through each item.
  86. for item in value:
  87. self._process_query_param(key, item)
  88. else:
  89. self._process_query_param(key, value)
  90. def _serialize_params(self, key, params):
  91. """
  92. Serialize dynamic or static query params to JSON and add the serialized value to
  93. the widget attributes by `key`.
  94. """
  95. # Deserialize the current serialized value from the widget, using an empty JSON
  96. # array as a fallback in the event one is not defined.
  97. current = json.loads(self.attrs.get(key, '[]'))
  98. # Combine the current values with the updated values and serialize the result as
  99. # JSON. Note: the `separators` kwarg effectively removes extra whitespace from
  100. # the serialized JSON string, which is ideal since these will be passed as
  101. # attributes to HTML elements and parsed on the client.
  102. self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
  103. def _add_dynamic_params(self):
  104. """
  105. Convert post-processed dynamic query params to data structure expected by front-
  106. end, serialize the value to JSON, and add it to the widget attributes.
  107. """
  108. key = 'data-dynamic-params'
  109. if len(self.dynamic_params) > 0:
  110. try:
  111. update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
  112. self._serialize_params(key, update)
  113. except IndexError as error:
  114. raise RuntimeError(
  115. _("Missing required value for dynamic query param: '{dynamic_params}'").format(
  116. dynamic_params=self.dynamic_params
  117. )
  118. ) from error
  119. def _add_static_params(self):
  120. """
  121. Convert post-processed static query params to data structure expected by front-
  122. end, serialize the value to JSON, and add it to the widget attributes.
  123. """
  124. key = 'data-static-params'
  125. if len(self.static_params) > 0:
  126. try:
  127. update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
  128. self._serialize_params(key, update)
  129. except IndexError as error:
  130. raise RuntimeError(
  131. _("Missing required value for static query param: '{static_params}'").format(
  132. static_params=self.static_params
  133. )
  134. ) from error
  135. def add_query_params(self, query_params):
  136. """
  137. Proccess & add a dictionary of URL query parameters to the widget attributes.
  138. """
  139. # Process query parameters. This populates `self.dynamic_params` and `self.static_params`.
  140. self._process_query_params(query_params)
  141. # Add processed dynamic parameters to widget attributes.
  142. self._add_dynamic_params()
  143. # Add processed static parameters to widget attributes.
  144. self._add_static_params()
  145. def add_query_param(self, key, value) -> None:
  146. """
  147. Process & add a key/value pair of URL query parameters to the widget attributes.
  148. """
  149. self.add_query_params({key: value})
  150. class APISelectMultiple(APISelect, forms.SelectMultiple):
  151. pass