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