forms.py 24 KB

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