widgets.py 10 KB

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