forms.py 29 KB

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