widgets.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import json
  2. from typing import Dict, Sequence, List, Tuple, Union
  3. from django import forms
  4. from django.conf import settings
  5. from django.contrib.postgres.forms import SimpleArrayField
  6. from utilities.choices import ColorChoices
  7. from .utils import add_blank_choice, parse_numeric_range
  8. __all__ = (
  9. 'APISelect',
  10. 'APISelectMultiple',
  11. 'BulkEditNullBooleanSelect',
  12. 'ClearableFileInput',
  13. 'ColorSelect',
  14. 'DatePicker',
  15. 'DateTimePicker',
  16. 'NumericArrayField',
  17. 'SelectSpeedWidget',
  18. 'SelectWithDisabled',
  19. 'SelectWithPK',
  20. 'SlugWidget',
  21. 'SmallTextarea',
  22. 'StaticSelect',
  23. 'StaticSelectMultiple',
  24. 'TimePicker',
  25. )
  26. JSONPrimitive = Union[str, bool, int, float, None]
  27. QueryParamValue = Union[JSONPrimitive, Sequence[JSONPrimitive]]
  28. QueryParam = Dict[str, QueryParamValue]
  29. ProcessedParams = Sequence[Dict[str, Sequence[JSONPrimitive]]]
  30. class SmallTextarea(forms.Textarea):
  31. """
  32. Subclass used for rendering a smaller textarea element.
  33. """
  34. pass
  35. class SlugWidget(forms.TextInput):
  36. """
  37. Subclass TextInput and add a slug regeneration button next to the form field.
  38. """
  39. template_name = 'widgets/sluginput.html'
  40. class ColorSelect(forms.Select):
  41. """
  42. Extends the built-in Select widget to colorize each <option>.
  43. """
  44. option_template_name = 'widgets/colorselect_option.html'
  45. def __init__(self, *args, **kwargs):
  46. kwargs['choices'] = add_blank_choice(ColorChoices)
  47. super().__init__(*args, **kwargs)
  48. self.attrs['class'] = 'netbox-color-select'
  49. class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
  50. """
  51. A Select widget for NullBooleanFields
  52. """
  53. def __init__(self, *args, **kwargs):
  54. super().__init__(*args, **kwargs)
  55. # Override the built-in choice labels
  56. self.choices = (
  57. ('1', '---------'),
  58. ('2', 'Yes'),
  59. ('3', 'No'),
  60. )
  61. self.attrs['class'] = 'netbox-static-select'
  62. class SelectWithDisabled(forms.Select):
  63. """
  64. Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
  65. 'label' (string) and 'disabled' (boolean).
  66. """
  67. option_template_name = 'widgets/selectwithdisabled_option.html'
  68. class StaticSelect(SelectWithDisabled):
  69. """
  70. A static <select/> form widget which is client-side rendered.
  71. """
  72. def __init__(self, *args, **kwargs):
  73. super().__init__(*args, **kwargs)
  74. self.attrs['class'] = 'netbox-static-select'
  75. class StaticSelectMultiple(StaticSelect, forms.SelectMultiple):
  76. def __init__(self, *args, **kwargs):
  77. super().__init__(*args, **kwargs)
  78. self.attrs['data-multiple'] = 1
  79. class SelectWithPK(StaticSelect):
  80. """
  81. Include the primary key of each option in the option label (e.g. "Router7 (4721)").
  82. """
  83. option_template_name = 'widgets/select_option_with_pk.html'
  84. class SelectSpeedWidget(forms.NumberInput):
  85. """
  86. Speed field with dropdown selections for convenience.
  87. """
  88. template_name = 'widgets/select_speed.html'
  89. class NumericArrayField(SimpleArrayField):
  90. def to_python(self, value):
  91. if not value:
  92. return []
  93. if isinstance(value, str):
  94. value = ','.join([str(n) for n in parse_numeric_range(value)])
  95. return super().to_python(value)
  96. class ClearableFileInput(forms.ClearableFileInput):
  97. """
  98. Override Django's stock ClearableFileInput with a custom template.
  99. """
  100. template_name = 'widgets/clearable_file_input.html'
  101. class APISelect(SelectWithDisabled):
  102. """
  103. A select widget populated via an API call
  104. :param api_url: API endpoint URL. Required if not set automatically by the parent field.
  105. """
  106. dynamic_params: Dict[str, str]
  107. static_params: Dict[str, List[str]]
  108. def __init__(self, api_url=None, full=False, *args, **kwargs):
  109. super().__init__(*args, **kwargs)
  110. self.attrs['class'] = 'netbox-api-select'
  111. self.dynamic_params: Dict[str, List[str]] = {}
  112. self.static_params: Dict[str, List[str]] = {}
  113. if api_url:
  114. self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  115. def __deepcopy__(self, memo):
  116. """Reset `static_params` and `dynamic_params` when APISelect is deepcopied."""
  117. result = super().__deepcopy__(memo)
  118. result.dynamic_params = {}
  119. result.static_params = {}
  120. return result
  121. def _process_query_param(self, key: str, value: JSONPrimitive) -> None:
  122. """
  123. Based on query param value's type and value, update instance's dynamic/static params.
  124. """
  125. if isinstance(value, str):
  126. # Coerce `True` boolean.
  127. if value.lower() == 'true':
  128. value = True
  129. # Coerce `False` boolean.
  130. elif value.lower() == 'false':
  131. value = False
  132. # Query parameters cannot have a `None` (or `null` in JSON) type, convert
  133. # `None` types to `'null'` so that ?key=null is used in the query URL.
  134. elif value is None:
  135. value = 'null'
  136. # Check type of `value` again, since it may have changed.
  137. if isinstance(value, str):
  138. if value.startswith('$'):
  139. # A value starting with `$` indicates a dynamic query param, where the
  140. # initial value is unknown and will be updated at the JavaScript layer
  141. # as the related form field's value changes.
  142. field_name = value.strip('$')
  143. self.dynamic_params[field_name] = key
  144. else:
  145. # A value _not_ starting with `$` indicates a static query param, where
  146. # the value is already known and should not be changed at the JavaScript
  147. # layer.
  148. if key in self.static_params:
  149. current = self.static_params[key]
  150. self.static_params[key] = [v for v in set([*current, value])]
  151. else:
  152. self.static_params[key] = [value]
  153. else:
  154. # Any non-string values are passed through as static query params, since
  155. # dynamic query param values have to be a string (in order to start with
  156. # `$`).
  157. if key in self.static_params:
  158. current = self.static_params[key]
  159. self.static_params[key] = [v for v in set([*current, value])]
  160. else:
  161. self.static_params[key] = [value]
  162. def _process_query_params(self, query_params: QueryParam) -> None:
  163. """
  164. Process an entire query_params dictionary, and handle primitive or list values.
  165. """
  166. for key, value in query_params.items():
  167. if isinstance(value, (List, Tuple)):
  168. # If value is a list/tuple, iterate through each item.
  169. for item in value:
  170. self._process_query_param(key, item)
  171. else:
  172. self._process_query_param(key, value)
  173. def _serialize_params(self, key: str, params: ProcessedParams) -> None:
  174. """
  175. Serialize dynamic or static query params to JSON and add the serialized value to
  176. the widget attributes by `key`.
  177. """
  178. # Deserialize the current serialized value from the widget, using an empty JSON
  179. # array as a fallback in the event one is not defined.
  180. current = json.loads(self.attrs.get(key, '[]'))
  181. # Combine the current values with the updated values and serialize the result as
  182. # JSON. Note: the `separators` kwarg effectively removes extra whitespace from
  183. # the serialized JSON string, which is ideal since these will be passed as
  184. # attributes to HTML elements and parsed on the client.
  185. self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
  186. def _add_dynamic_params(self) -> None:
  187. """
  188. Convert post-processed dynamic query params to data structure expected by front-
  189. end, serialize the value to JSON, and add it to the widget attributes.
  190. """
  191. key = 'data-dynamic-params'
  192. if len(self.dynamic_params) > 0:
  193. try:
  194. update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
  195. self._serialize_params(key, update)
  196. except IndexError as error:
  197. raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
  198. def _add_static_params(self) -> None:
  199. """
  200. Convert post-processed static query params to data structure expected by front-
  201. end, serialize the value to JSON, and add it to the widget attributes.
  202. """
  203. key = 'data-static-params'
  204. if len(self.static_params) > 0:
  205. try:
  206. update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
  207. self._serialize_params(key, update)
  208. except IndexError as error:
  209. raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
  210. def add_query_params(self, query_params: QueryParam) -> None:
  211. """
  212. Proccess & add a dictionary of URL query parameters to the widget attributes.
  213. """
  214. # Process query parameters. This populates `self.dynamic_params` and `self.static_params`.
  215. self._process_query_params(query_params)
  216. # Add processed dynamic parameters to widget attributes.
  217. self._add_dynamic_params()
  218. # Add processed static parameters to widget attributes.
  219. self._add_static_params()
  220. def add_query_param(self, key: str, value: QueryParamValue) -> None:
  221. """
  222. Process & add a key/value pair of URL query parameters to the widget attributes.
  223. """
  224. self.add_query_params({key: value})
  225. class APISelectMultiple(APISelect, forms.SelectMultiple):
  226. def __init__(self, *args, **kwargs):
  227. super().__init__(*args, **kwargs)
  228. self.attrs['data-multiple'] = 1
  229. class DatePicker(forms.TextInput):
  230. """
  231. Date picker using Flatpickr.
  232. """
  233. def __init__(self, *args, **kwargs):
  234. super().__init__(*args, **kwargs)
  235. self.attrs['class'] = 'date-picker'
  236. self.attrs['placeholder'] = 'YYYY-MM-DD'
  237. class DateTimePicker(forms.TextInput):
  238. """
  239. DateTime picker using Flatpickr.
  240. """
  241. def __init__(self, *args, **kwargs):
  242. super().__init__(*args, **kwargs)
  243. self.attrs['class'] = 'datetime-picker'
  244. self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
  245. class TimePicker(forms.TextInput):
  246. """
  247. Time picker using Flatpickr.
  248. """
  249. def __init__(self, *args, **kwargs):
  250. super().__init__(*args, **kwargs)
  251. self.attrs['class'] = 'time-picker'
  252. self.attrs['placeholder'] = 'hh:mm:ss'