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

1import json 

2 

3from django import forms 

4from django.conf import settings 

5from django.utils.translation import gettext_lazy as _ 

6 

7__all__ = ( 

8 'APISelect', 

9 'APISelectMultiple', 

10) 

11 

12 

13class APISelect(forms.Select): 

14 """ 

15 A select widget populated via an API call 

16 

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]] 

23 

24 def get_context(self, name, value, attrs): 

25 context = super().get_context(name, value, attrs) 

26 

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 

30 

31 return context 

32 

33 def __init__(self, api_url=None, full=False, *args, **kwargs): 

34 super().__init__(*args, **kwargs) 

35 

36 self.attrs['class'] = 'api-select' 

37 self.dynamic_params: dict[str, list[str]] = {} 

38 self.static_params: dict[str, list[str]] = {} 

39 

40 if api_url: 

41 self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH 

42 

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 

49 

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' 

65 

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] 

92 

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 

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, '[]')) 

113 

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=(',', ':')) 

119 

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 

136 

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 

153 

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() 

164 

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}) 

170 

171 

172class APISelectMultiple(APISelect, forms.SelectMultiple): 

173 pass