apiselect.py 7.3 KB

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