forms.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  1. import csv
  2. import json
  3. import re
  4. from io import StringIO
  5. import django_filters
  6. import yaml
  7. from django import forms
  8. from django.conf import settings
  9. from django.contrib.postgres.forms import SimpleArrayField
  10. from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
  11. from django.core.exceptions import MultipleObjectsReturned
  12. from django.db.models import Count
  13. from django.forms import BoundField
  14. from django.forms.models import fields_for_model
  15. from django.urls import reverse
  16. from .choices import ColorChoices, unpack_grouped_choices
  17. from .validators import EnhancedURLValidator
  18. NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
  19. ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
  20. IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
  21. IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
  22. BOOLEAN_WITH_BLANK_CHOICES = (
  23. ('', '---------'),
  24. ('True', 'Yes'),
  25. ('False', 'No'),
  26. )
  27. def parse_numeric_range(string, base=10):
  28. """
  29. Expand a numeric range (continuous or not) into a decimal or
  30. hexadecimal list, as specified by the base parameter
  31. '0-3,5' => [0, 1, 2, 3, 5]
  32. '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
  33. """
  34. values = list()
  35. for dash_range in string.split(','):
  36. try:
  37. begin, end = dash_range.split('-')
  38. except ValueError:
  39. begin, end = dash_range, dash_range
  40. begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
  41. values.extend(range(begin, end))
  42. return list(set(values))
  43. def parse_alphanumeric_range(string):
  44. """
  45. Expand an alphanumeric range (continuous or not) into a list.
  46. 'a-d,f' => [a, b, c, d, f]
  47. '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
  48. """
  49. values = []
  50. for dash_range in string.split(','):
  51. try:
  52. begin, end = dash_range.split('-')
  53. vals = begin + end
  54. # Break out of loop if there's an invalid pattern to return an error
  55. if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
  56. return []
  57. except ValueError:
  58. begin, end = dash_range, dash_range
  59. if begin.isdigit() and end.isdigit():
  60. for n in list(range(int(begin), int(end) + 1)):
  61. values.append(n)
  62. else:
  63. # Value-based
  64. if begin == end:
  65. values.append(begin)
  66. # Range-based
  67. else:
  68. # Not a valid range (more than a single character)
  69. if not len(begin) == len(end) == 1:
  70. raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range))
  71. for n in list(range(ord(begin), ord(end) + 1)):
  72. values.append(chr(n))
  73. return values
  74. def expand_alphanumeric_pattern(string):
  75. """
  76. Expand an alphabetic pattern into a list of strings.
  77. """
  78. lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
  79. parsed_range = parse_alphanumeric_range(pattern)
  80. for i in parsed_range:
  81. if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
  82. for string in expand_alphanumeric_pattern(remnant):
  83. yield "{}{}{}".format(lead, i, string)
  84. else:
  85. yield "{}{}{}".format(lead, i, remnant)
  86. def expand_ipaddress_pattern(string, family):
  87. """
  88. Expand an IP address pattern into a list of strings. Examples:
  89. '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24']
  90. '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']
  91. """
  92. if family not in [4, 6]:
  93. raise Exception("Invalid IP address family: {}".format(family))
  94. if family == 4:
  95. regex = IP4_EXPANSION_PATTERN
  96. base = 10
  97. else:
  98. regex = IP6_EXPANSION_PATTERN
  99. base = 16
  100. lead, pattern, remnant = re.split(regex, string, maxsplit=1)
  101. parsed_range = parse_numeric_range(pattern, base)
  102. for i in parsed_range:
  103. if re.search(regex, remnant):
  104. for string in expand_ipaddress_pattern(remnant, family):
  105. yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string])
  106. else:
  107. yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant])
  108. def add_blank_choice(choices):
  109. """
  110. Add a blank choice to the beginning of a choices list.
  111. """
  112. return ((None, '---------'),) + tuple(choices)
  113. def form_from_model(model, fields):
  114. """
  115. Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
  116. for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
  117. are marked as not required.
  118. """
  119. form_fields = fields_for_model(model, fields=fields)
  120. for field in form_fields.values():
  121. field.required = False
  122. return type('FormFromModel', (forms.Form,), form_fields)
  123. #
  124. # Widgets
  125. #
  126. class SmallTextarea(forms.Textarea):
  127. """
  128. Subclass used for rendering a smaller textarea element.
  129. """
  130. pass
  131. class SlugWidget(forms.TextInput):
  132. """
  133. Subclass TextInput and add a slug regeneration button next to the form field.
  134. """
  135. template_name = 'widgets/sluginput.html'
  136. class ColorSelect(forms.Select):
  137. """
  138. Extends the built-in Select widget to colorize each <option>.
  139. """
  140. option_template_name = 'widgets/colorselect_option.html'
  141. def __init__(self, *args, **kwargs):
  142. kwargs['choices'] = add_blank_choice(ColorChoices)
  143. super().__init__(*args, **kwargs)
  144. self.attrs['class'] = 'netbox-select2-color-picker'
  145. class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
  146. """
  147. A Select widget for NullBooleanFields
  148. """
  149. def __init__(self, *args, **kwargs):
  150. super().__init__(*args, **kwargs)
  151. # Override the built-in choice labels
  152. self.choices = (
  153. ('1', '---------'),
  154. ('2', 'Yes'),
  155. ('3', 'No'),
  156. )
  157. self.attrs['class'] = 'netbox-select2-static'
  158. class SelectWithDisabled(forms.Select):
  159. """
  160. Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
  161. 'label' (string) and 'disabled' (boolean).
  162. """
  163. option_template_name = 'widgets/selectwithdisabled_option.html'
  164. class StaticSelect2(SelectWithDisabled):
  165. """
  166. A static content using the Select2 widget
  167. :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the
  168. name of the filter-for field (child field) and the value is the name of the query param filter.
  169. """
  170. def __init__(self, filter_for=None, *args, **kwargs):
  171. super().__init__(*args, **kwargs)
  172. self.attrs['class'] = 'netbox-select2-static'
  173. if filter_for:
  174. for key, value in filter_for.items():
  175. self.add_filter_for(key, value)
  176. def add_filter_for(self, name, value):
  177. """
  178. Add details for an additional query param in the form of a data-filter-for-* attribute.
  179. :param name: The name of the query param
  180. :param value: The value of the query param
  181. """
  182. self.attrs['data-filter-for-{}'.format(name)] = value
  183. class StaticSelect2Multiple(StaticSelect2, forms.SelectMultiple):
  184. def __init__(self, *args, **kwargs):
  185. super().__init__(*args, **kwargs)
  186. self.attrs['data-multiple'] = 1
  187. class SelectWithPK(StaticSelect2):
  188. """
  189. Include the primary key of each option in the option label (e.g. "Router7 (4721)").
  190. """
  191. option_template_name = 'widgets/select_option_with_pk.html'
  192. class ContentTypeSelect(StaticSelect2):
  193. """
  194. Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
  195. <option value="37" api-value="console-server-port">console server port</option>
  196. This attribute can be used to reference the relevant API endpoint for a particular ContentType.
  197. """
  198. option_template_name = 'widgets/select_contenttype.html'
  199. class NumericArrayField(SimpleArrayField):
  200. def to_python(self, value):
  201. value = ','.join([str(n) for n in parse_numeric_range(value)])
  202. return super().to_python(value)
  203. class APISelect(SelectWithDisabled):
  204. """
  205. A select widget populated via an API call
  206. :param api_url: API endpoint URL. Required if not set automatically by the parent field.
  207. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
  208. :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`.
  209. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
  210. :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the
  211. name of the filter-for field (child field) and the value is the name of the query param filter.
  212. :param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the
  213. condition is met. The condition is the dict key and is specified in the form `<field_name>__<field_value>`.
  214. If the provided field value is selected for the given field, the URL query param will be appended to
  215. the rendered URL. The value is the in the from `<param_name>=<param_value>`. This is useful in cases where
  216. a particular field value dictates an additional API filter.
  217. :param additional_query_params: Optional) A dict of query params to append to the API request. The key is the
  218. name of the query param and the value if the query param's value.
  219. :param null_option: If true, include the static null option in the selection list.
  220. """
  221. def __init__(
  222. self,
  223. api_url=None,
  224. display_field=None,
  225. value_field=None,
  226. disabled_indicator=None,
  227. filter_for=None,
  228. conditional_query_params=None,
  229. additional_query_params=None,
  230. null_option=False,
  231. full=False,
  232. *args,
  233. **kwargs
  234. ):
  235. super().__init__(*args, **kwargs)
  236. self.attrs['class'] = 'netbox-select2-api'
  237. if api_url:
  238. self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  239. if full:
  240. self.attrs['data-full'] = full
  241. if display_field:
  242. self.attrs['display-field'] = display_field
  243. if value_field:
  244. self.attrs['value-field'] = value_field
  245. if disabled_indicator:
  246. self.attrs['disabled-indicator'] = disabled_indicator
  247. if filter_for:
  248. for key, value in filter_for.items():
  249. self.add_filter_for(key, value)
  250. if conditional_query_params:
  251. for key, value in conditional_query_params.items():
  252. self.add_conditional_query_param(key, value)
  253. if additional_query_params:
  254. for key, value in additional_query_params.items():
  255. self.add_additional_query_param(key, value)
  256. if null_option:
  257. self.attrs['data-null-option'] = 1
  258. def add_filter_for(self, name, value):
  259. """
  260. Add details for an additional query param in the form of a data-filter-for-* attribute.
  261. :param name: The name of the query param
  262. :param value: The value of the query param
  263. """
  264. self.attrs['data-filter-for-{}'.format(name)] = value
  265. def add_additional_query_param(self, name, value):
  266. """
  267. Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
  268. :param name: The name of the query param
  269. :param value: The value of the query param
  270. """
  271. key = 'data-additional-query-param-{}'.format(name)
  272. values = json.loads(self.attrs.get(key, '[]'))
  273. values.append(value)
  274. self.attrs[key] = json.dumps(values)
  275. def add_conditional_query_param(self, condition, value):
  276. """
  277. Add details for a URL query strings to append to the URL if the condition is met.
  278. The condition is specified in the form `<field_name>__<field_value>`.
  279. :param condition: The condition for the query param
  280. :param value: The value of the query param
  281. """
  282. self.attrs['data-conditional-query-param-{}'.format(condition)] = value
  283. class APISelectMultiple(APISelect, forms.SelectMultiple):
  284. def __init__(self, *args, **kwargs):
  285. super().__init__(*args, **kwargs)
  286. self.attrs['data-multiple'] = 1
  287. class DatePicker(forms.TextInput):
  288. """
  289. Date picker using Flatpickr.
  290. """
  291. def __init__(self, *args, **kwargs):
  292. super().__init__(*args, **kwargs)
  293. self.attrs['class'] = 'date-picker'
  294. self.attrs['placeholder'] = 'YYYY-MM-DD'
  295. class DateTimePicker(forms.TextInput):
  296. """
  297. DateTime picker using Flatpickr.
  298. """
  299. def __init__(self, *args, **kwargs):
  300. super().__init__(*args, **kwargs)
  301. self.attrs['class'] = 'datetime-picker'
  302. self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
  303. class TimePicker(forms.TextInput):
  304. """
  305. Time picker using Flatpickr.
  306. """
  307. def __init__(self, *args, **kwargs):
  308. super().__init__(*args, **kwargs)
  309. self.attrs['class'] = 'time-picker'
  310. self.attrs['placeholder'] = 'hh:mm:ss'
  311. #
  312. # Form fields
  313. #
  314. class CSVDataField(forms.CharField):
  315. """
  316. A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
  317. item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
  318. (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
  319. :param from_form: The form from which the field derives its validation rules.
  320. """
  321. widget = forms.Textarea
  322. def __init__(self, from_form, *args, **kwargs):
  323. form = from_form()
  324. self.model = form.Meta.model
  325. self.fields = form.fields
  326. self.required_fields = [
  327. name for name, field in form.fields.items() if field.required
  328. ]
  329. super().__init__(*args, **kwargs)
  330. self.strip = False
  331. if not self.label:
  332. self.label = ''
  333. if not self.initial:
  334. self.initial = ','.join(self.required_fields) + '\n'
  335. if not self.help_text:
  336. self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
  337. 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
  338. 'in double quotes.'
  339. def to_python(self, value):
  340. records = []
  341. reader = csv.reader(StringIO(value.strip()))
  342. # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
  343. # "to" field specifying how the related object is being referenced. For example, importing a Device might use a
  344. # `site.slug` header, to indicate the related site is being referenced by its slug.
  345. headers = {}
  346. for header in next(reader):
  347. if '.' in header:
  348. field, to_field = header.split('.', 1)
  349. headers[field] = to_field
  350. else:
  351. headers[header] = None
  352. # Parse CSV rows into a list of dictionaries mapped from the column headers.
  353. for i, row in enumerate(reader, start=1):
  354. if len(row) != len(headers):
  355. raise forms.ValidationError(
  356. f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
  357. )
  358. row = [col.strip() for col in row]
  359. record = dict(zip(headers.keys(), row))
  360. records.append(record)
  361. return headers, records
  362. def validate(self, value):
  363. headers, records = value
  364. # Validate provided column headers
  365. for field, to_field in headers.items():
  366. if field not in self.fields:
  367. raise forms.ValidationError(f'Unexpected column header "{field}" found.')
  368. if to_field and not hasattr(self.fields[field], 'to_field_name'):
  369. raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
  370. if to_field and not hasattr(self.fields[field].queryset.model, to_field):
  371. raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
  372. # Validate required fields
  373. for f in self.required_fields:
  374. if f not in headers:
  375. raise forms.ValidationError(f'Required column header "{f}" not found.')
  376. return value
  377. class CSVChoiceField(forms.ChoiceField):
  378. """
  379. Invert the provided set of choices to take the human-friendly label as input, and return the database value.
  380. """
  381. def __init__(self, choices, *args, **kwargs):
  382. super().__init__(choices=choices, *args, **kwargs)
  383. self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
  384. self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)}
  385. def clean(self, value):
  386. value = super().clean(value)
  387. if not value:
  388. return ''
  389. if value not in self.choice_values:
  390. raise forms.ValidationError("Invalid choice: {}".format(value))
  391. return self.choice_values[value]
  392. class CSVModelChoiceField(forms.ModelChoiceField):
  393. """
  394. Provides additional validation for model choices entered as CSV data.
  395. """
  396. default_error_messages = {
  397. 'invalid_choice': 'Object not found.',
  398. }
  399. def to_python(self, value):
  400. try:
  401. return super().to_python(value)
  402. except MultipleObjectsReturned as e:
  403. raise forms.ValidationError(
  404. f'"{value}" is not a unique value for this field; multiple objects were found'
  405. )
  406. class ExpandableNameField(forms.CharField):
  407. """
  408. A field which allows for numeric range expansion
  409. Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
  410. """
  411. def __init__(self, *args, **kwargs):
  412. super().__init__(*args, **kwargs)
  413. if not self.help_text:
  414. self.help_text = """
  415. Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
  416. are not supported. Examples:
  417. <ul>
  418. <li><code>[ge,xe]-0/0/[0-9]</code></li>
  419. <li><code>e[0-3][a-d,f]</code></li>
  420. </ul>
  421. """
  422. def to_python(self, value):
  423. if value is None:
  424. return list()
  425. if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
  426. return list(expand_alphanumeric_pattern(value))
  427. return [value]
  428. class ExpandableIPAddressField(forms.CharField):
  429. """
  430. A field which allows for expansion of IP address ranges
  431. Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']
  432. """
  433. def __init__(self, *args, **kwargs):
  434. super().__init__(*args, **kwargs)
  435. if not self.help_text:
  436. self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
  437. 'Example: <code>192.0.2.[1,5,100-254]/24</code>'
  438. def to_python(self, value):
  439. # Hackish address family detection but it's all we have to work with
  440. if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
  441. return list(expand_ipaddress_pattern(value, 4))
  442. elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
  443. return list(expand_ipaddress_pattern(value, 6))
  444. return [value]
  445. class CommentField(forms.CharField):
  446. """
  447. A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text.
  448. """
  449. widget = forms.Textarea
  450. default_label = ''
  451. # TODO: Port Markdown cheat sheet to internal documentation
  452. default_helptext = '<i class="fa fa-info-circle"></i> '\
  453. '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
  454. 'Markdown</a> syntax is supported'
  455. def __init__(self, *args, **kwargs):
  456. required = kwargs.pop('required', False)
  457. label = kwargs.pop('label', self.default_label)
  458. help_text = kwargs.pop('help_text', self.default_helptext)
  459. super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
  460. class SlugField(forms.SlugField):
  461. """
  462. Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
  463. """
  464. def __init__(self, slug_source='name', *args, **kwargs):
  465. label = kwargs.pop('label', "Slug")
  466. help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
  467. widget = kwargs.pop('widget', SlugWidget)
  468. super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
  469. self.widget.attrs['slug-source'] = slug_source
  470. class TagFilterField(forms.MultipleChoiceField):
  471. """
  472. A filter field for the tags of a model. Only the tags used by a model are displayed.
  473. :param model: The model of the filter
  474. """
  475. widget = StaticSelect2Multiple
  476. def __init__(self, model, *args, **kwargs):
  477. def get_choices():
  478. tags = model.tags.all().unrestricted().annotate(
  479. count=Count('extras_taggeditem_items')
  480. ).order_by('name')
  481. return [
  482. (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
  483. ]
  484. # Choices are fetched each time the form is initialized
  485. super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
  486. class DynamicModelChoiceMixin:
  487. filter = django_filters.ModelChoiceFilter
  488. widget = APISelect
  489. def _get_initial_value(self, initial_data, field_name):
  490. return initial_data.get(field_name)
  491. def get_bound_field(self, form, field_name):
  492. bound_field = BoundField(form, self, field_name)
  493. # Override initial() to allow passing multiple values
  494. bound_field.initial = self._get_initial_value(form.initial, field_name)
  495. # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
  496. # will be populated on-demand via the APISelect widget.
  497. data = bound_field.value()
  498. if data:
  499. filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
  500. self.queryset = filter.filter(self.queryset, data)
  501. else:
  502. self.queryset = self.queryset.none()
  503. # Set the data URL on the APISelect widget (if not already set)
  504. widget = bound_field.field.widget
  505. if not widget.attrs.get('data-url'):
  506. app_label = self.queryset.model._meta.app_label
  507. model_name = self.queryset.model._meta.model_name
  508. data_url = reverse('{}-api:{}-list'.format(app_label, model_name))
  509. widget.attrs['data-url'] = data_url
  510. return bound_field
  511. class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
  512. """
  513. Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
  514. rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
  515. """
  516. pass
  517. class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
  518. """
  519. A multiple-choice version of DynamicModelChoiceField.
  520. """
  521. filter = django_filters.ModelMultipleChoiceFilter
  522. widget = APISelectMultiple
  523. def _get_initial_value(self, initial_data, field_name):
  524. # If a QueryDict has been passed as initial form data, get *all* listed values
  525. if hasattr(initial_data, 'getlist'):
  526. return initial_data.getlist(field_name)
  527. return initial_data.get(field_name)
  528. class LaxURLField(forms.URLField):
  529. """
  530. Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
  531. (e.g. http://myserver/ is valid)
  532. """
  533. default_validators = [EnhancedURLValidator()]
  534. class JSONField(_JSONField):
  535. """
  536. Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
  537. """
  538. def __init__(self, *args, **kwargs):
  539. super().__init__(*args, **kwargs)
  540. if not self.help_text:
  541. self.help_text = 'Enter context data in <a href="https://json.org/">JSON</a> format.'
  542. self.widget.attrs['placeholder'] = ''
  543. def prepare_value(self, value):
  544. if isinstance(value, InvalidJSONInput):
  545. return value
  546. if value is None:
  547. return ''
  548. return json.dumps(value, sort_keys=True, indent=4)
  549. #
  550. # Forms
  551. #
  552. class BootstrapMixin(forms.BaseForm):
  553. """
  554. Add the base Bootstrap CSS classes to form elements.
  555. """
  556. def __init__(self, *args, **kwargs):
  557. super().__init__(*args, **kwargs)
  558. exempt_widgets = [
  559. forms.CheckboxInput,
  560. forms.ClearableFileInput,
  561. forms.FileInput,
  562. forms.RadioSelect
  563. ]
  564. for field_name, field in self.fields.items():
  565. if field.widget.__class__ not in exempt_widgets:
  566. css = field.widget.attrs.get('class', '')
  567. field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
  568. if field.required and not isinstance(field.widget, forms.FileInput):
  569. field.widget.attrs['required'] = 'required'
  570. if 'placeholder' not in field.widget.attrs:
  571. field.widget.attrs['placeholder'] = field.label
  572. class ReturnURLForm(forms.Form):
  573. """
  574. Provides a hidden return URL field to control where the user is directed after the form is submitted.
  575. """
  576. return_url = forms.CharField(required=False, widget=forms.HiddenInput())
  577. class ConfirmationForm(BootstrapMixin, ReturnURLForm):
  578. """
  579. A generic confirmation form. The form is not valid unless the confirm field is checked.
  580. """
  581. confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
  582. class BulkEditForm(forms.Form):
  583. """
  584. Base form for editing multiple objects in bulk
  585. """
  586. def __init__(self, model, *args, **kwargs):
  587. super().__init__(*args, **kwargs)
  588. self.model = model
  589. self.nullable_fields = []
  590. # Copy any nullable fields defined in Meta
  591. if hasattr(self.Meta, 'nullable_fields'):
  592. self.nullable_fields = self.Meta.nullable_fields
  593. class CSVModelForm(forms.ModelForm):
  594. """
  595. ModelForm used for the import of objects in CSV format.
  596. """
  597. def __init__(self, *args, headers=None, **kwargs):
  598. super().__init__(*args, **kwargs)
  599. # Modify the model form to accommodate any customized to_field_name properties
  600. if headers:
  601. for field, to_field in headers.items():
  602. if to_field is not None:
  603. self.fields[field].to_field_name = to_field
  604. class ImportForm(BootstrapMixin, forms.Form):
  605. """
  606. Generic form for creating an object from JSON/YAML data
  607. """
  608. data = forms.CharField(
  609. widget=forms.Textarea,
  610. help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported."
  611. )
  612. format = forms.ChoiceField(
  613. choices=(
  614. ('json', 'JSON'),
  615. ('yaml', 'YAML')
  616. ),
  617. initial='yaml'
  618. )
  619. def clean(self):
  620. data = self.cleaned_data['data']
  621. format = self.cleaned_data['format']
  622. # Process JSON/YAML data
  623. if format == 'json':
  624. try:
  625. self.cleaned_data['data'] = json.loads(data)
  626. # Check for multiple JSON objects
  627. if type(self.cleaned_data['data']) is not dict:
  628. raise forms.ValidationError({
  629. 'data': "Import is limited to one object at a time."
  630. })
  631. except json.decoder.JSONDecodeError as err:
  632. raise forms.ValidationError({
  633. 'data': "Invalid JSON data: {}".format(err)
  634. })
  635. else:
  636. # Check for multiple YAML documents
  637. if '\n---' in data:
  638. raise forms.ValidationError({
  639. 'data': "Import is limited to one object at a time."
  640. })
  641. try:
  642. self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
  643. except yaml.error.YAMLError as err:
  644. raise forms.ValidationError({
  645. 'data': "Invalid YAML data: {}".format(err)
  646. })
  647. class LabeledComponentForm(BootstrapMixin, forms.Form):
  648. """
  649. Base form for adding label pattern validation to `Create` forms
  650. """
  651. name_pattern = ExpandableNameField(
  652. label='Name'
  653. )
  654. label_pattern = ExpandableNameField(
  655. label='Label',
  656. required=False
  657. )
  658. def clean(self):
  659. # Validate that the number of components being created from both the name_pattern and label_pattern are equal
  660. name_pattern_count = len(self.cleaned_data['name_pattern'])
  661. label_pattern_count = len(self.cleaned_data['label_pattern'])
  662. if label_pattern_count and name_pattern_count != label_pattern_count:
  663. raise forms.ValidationError({
  664. 'label_pattern': 'The provided name pattern will create {} components, however {} labels will '
  665. 'be generated. These counts must match.'.format(
  666. name_pattern_count, label_pattern_count)
  667. }, code='label_pattern_mismatch')
  668. class TableConfigForm(BootstrapMixin, forms.Form):
  669. """
  670. Form for configuring user's table preferences.
  671. """
  672. columns = forms.MultipleChoiceField(
  673. choices=[],
  674. widget=forms.SelectMultiple(
  675. attrs={'size': 10}
  676. ),
  677. help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display."
  678. )
  679. def __init__(self, table, *args, **kwargs):
  680. super().__init__(*args, **kwargs)
  681. # Initialize columns field based on table attributes
  682. self.fields['columns'].choices = table.configurable_columns
  683. self.fields['columns'].initial = table.visible_columns