forms.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. from __future__ import unicode_literals
  2. import csv
  3. import itertools
  4. import re
  5. from mptt.forms import TreeNodeMultipleChoiceField
  6. from django import forms
  7. from django.conf import settings
  8. from django.urls import reverse_lazy
  9. from .validators import EnhancedURLValidator
  10. COLOR_CHOICES = (
  11. ('aa1409', 'Dark red'),
  12. ('f44336', 'Red'),
  13. ('e91e63', 'Pink'),
  14. ('ff66ff', 'Fuschia'),
  15. ('9c27b0', 'Purple'),
  16. ('673ab7', 'Dark purple'),
  17. ('3f51b5', 'Indigo'),
  18. ('2196f3', 'Blue'),
  19. ('03a9f4', 'Light blue'),
  20. ('00bcd4', 'Cyan'),
  21. ('009688', 'Teal'),
  22. ('2f6a31', 'Dark green'),
  23. ('4caf50', 'Green'),
  24. ('8bc34a', 'Light green'),
  25. ('cddc39', 'Lime'),
  26. ('ffeb3b', 'Yellow'),
  27. ('ffc107', 'Amber'),
  28. ('ff9800', 'Orange'),
  29. ('ff5722', 'Dark orange'),
  30. ('795548', 'Brown'),
  31. ('c0c0c0', 'Light grey'),
  32. ('9e9e9e', 'Grey'),
  33. ('607d8b', 'Dark grey'),
  34. ('111111', 'Black'),
  35. )
  36. NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
  37. IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
  38. IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
  39. def parse_numeric_range(string, base=10):
  40. """
  41. Expand a numeric range (continuous or not) into a decimal or
  42. hexadecimal list, as specified by the base parameter
  43. '0-3,5' => [0, 1, 2, 3, 5]
  44. '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
  45. """
  46. values = list()
  47. for dash_range in string.split(','):
  48. try:
  49. begin, end = dash_range.split('-')
  50. except ValueError:
  51. begin, end = dash_range, dash_range
  52. begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
  53. values.extend(range(begin, end))
  54. return list(set(values))
  55. def expand_numeric_pattern(string):
  56. """
  57. Expand a numeric pattern into a list of strings. Examples:
  58. 'ge-0/0/[0-3,5]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3', 'ge-0/0/5']
  59. 'xe-0/[0,2-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7']
  60. """
  61. lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
  62. parsed_range = parse_numeric_range(pattern)
  63. for i in parsed_range:
  64. if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
  65. for string in expand_numeric_pattern(remnant):
  66. yield "{}{}{}".format(lead, i, string)
  67. else:
  68. yield "{}{}{}".format(lead, i, remnant)
  69. def expand_ipaddress_pattern(string, family):
  70. """
  71. Expand an IP address pattern into a list of strings. Examples:
  72. '192.0.2.[1,2,100-250,254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24', '192.0.2.254/24']
  73. '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']
  74. """
  75. if family not in [4, 6]:
  76. raise Exception("Invalid IP address family: {}".format(family))
  77. if family == 4:
  78. regex = IP4_EXPANSION_PATTERN
  79. base = 10
  80. else:
  81. regex = IP6_EXPANSION_PATTERN
  82. base = 16
  83. lead, pattern, remnant = re.split(regex, string, maxsplit=1)
  84. parsed_range = parse_numeric_range(pattern, base)
  85. for i in parsed_range:
  86. if re.search(regex, remnant):
  87. for string in expand_ipaddress_pattern(remnant, family):
  88. yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string])
  89. else:
  90. yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant])
  91. def add_blank_choice(choices):
  92. """
  93. Add a blank choice to the beginning of a choices list.
  94. """
  95. return ((None, '---------'),) + tuple(choices)
  96. #
  97. # Widgets
  98. #
  99. class SmallTextarea(forms.Textarea):
  100. pass
  101. class ColorSelect(forms.Select):
  102. """
  103. Extends the built-in Select widget to colorize each <option>.
  104. """
  105. option_template_name = 'colorselect_option.html'
  106. def __init__(self, *args, **kwargs):
  107. kwargs['choices'] = COLOR_CHOICES
  108. super(ColorSelect, self).__init__(*args, **kwargs)
  109. class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
  110. def __init__(self, *args, **kwargs):
  111. super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs)
  112. # Override the built-in choice labels
  113. self.choices = (
  114. ('1', '---------'),
  115. ('2', 'Yes'),
  116. ('3', 'No'),
  117. )
  118. class SelectWithDisabled(forms.Select):
  119. """
  120. Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
  121. 'label' (string) and 'disabled' (boolean).
  122. """
  123. option_template_name = 'selectwithdisabled_option.html'
  124. class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
  125. """
  126. MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
  127. """
  128. def __init__(self, *args, **kwargs):
  129. self.delimiter = kwargs.pop('delimiter', ',')
  130. super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs)
  131. def optgroups(self, name, value, attrs=None):
  132. # Split the delimited string of values into a list
  133. value = value[0].split(self.delimiter)
  134. return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
  135. def value_from_datadict(self, data, files, name):
  136. # Condense the list of selected choices into a delimited string
  137. data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name)
  138. return self.delimiter.join(data)
  139. class APISelect(SelectWithDisabled):
  140. """
  141. A select widget populated via an API call
  142. :param api_url: API URL
  143. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
  144. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
  145. """
  146. def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs):
  147. super(APISelect, self).__init__(*args, **kwargs)
  148. self.attrs['class'] = 'api-select'
  149. self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  150. if display_field:
  151. self.attrs['display-field'] = display_field
  152. if disabled_indicator:
  153. self.attrs['disabled-indicator'] = disabled_indicator
  154. class APISelectMultiple(APISelect):
  155. allow_multiple_selected = True
  156. class Livesearch(forms.TextInput):
  157. """
  158. A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
  159. :param query_key: The name of the parameter to query against
  160. :param query_url: The name of the API URL to query
  161. :param field_to_update: The name of the "real" form field whose value is being set
  162. :param obj_label: The field to use as the option label (optional)
  163. """
  164. def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs):
  165. super(Livesearch, self).__init__(*args, **kwargs)
  166. self.attrs = {
  167. 'data-key': query_key,
  168. 'data-source': reverse_lazy(query_url),
  169. 'data-field': field_to_update,
  170. }
  171. if obj_label:
  172. self.attrs['data-label'] = obj_label
  173. #
  174. # Form fields
  175. #
  176. class CSVDataField(forms.CharField):
  177. """
  178. A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
  179. column headers to values. Each dictionary represents an individual record.
  180. """
  181. widget = forms.Textarea
  182. def __init__(self, fields, required_fields=[], *args, **kwargs):
  183. self.fields = fields
  184. self.required_fields = required_fields
  185. super(CSVDataField, self).__init__(*args, **kwargs)
  186. self.strip = False
  187. if not self.label:
  188. self.label = 'CSV Data'
  189. if not self.initial:
  190. self.initial = ','.join(required_fields) + '\n'
  191. if not self.help_text:
  192. self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
  193. 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
  194. 'in double quotes.'
  195. def to_python(self, value):
  196. # Python 2's csv module has problems with Unicode
  197. if not isinstance(value, str):
  198. value = value.encode('utf-8')
  199. records = []
  200. reader = csv.reader(value.splitlines())
  201. # Consume and valdiate the first line of CSV data as column headers
  202. headers = next(reader)
  203. for f in self.required_fields:
  204. if f not in headers:
  205. raise forms.ValidationError('Required column header "{}" not found.'.format(f))
  206. for f in headers:
  207. if f not in self.fields:
  208. raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
  209. # Parse CSV data
  210. for i, row in enumerate(reader, start=1):
  211. if row:
  212. if len(row) != len(headers):
  213. raise forms.ValidationError(
  214. "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
  215. )
  216. row = [col.strip() for col in row]
  217. record = dict(zip(headers, row))
  218. records.append(record)
  219. return records
  220. class CSVChoiceField(forms.ChoiceField):
  221. """
  222. Invert the provided set of choices to take the human-friendly label as input, and return the database value.
  223. """
  224. def __init__(self, choices, *args, **kwargs):
  225. super(CSVChoiceField, self).__init__(choices, *args, **kwargs)
  226. self.choices = [(label, label) for value, label in choices]
  227. self.choice_values = {label: value for value, label in choices}
  228. def clean(self, value):
  229. value = super(CSVChoiceField, self).clean(value)
  230. if not value:
  231. return None
  232. if value not in self.choice_values:
  233. raise forms.ValidationError("Invalid choice: {}".format(value))
  234. return self.choice_values[value]
  235. class ExpandableNameField(forms.CharField):
  236. """
  237. A field which allows for numeric range expansion
  238. Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
  239. """
  240. def __init__(self, *args, **kwargs):
  241. super(ExpandableNameField, self).__init__(*args, **kwargs)
  242. if not self.help_text:
  243. self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
  244. 'Example: <code>ge-0/0/[0-23,25,30]</code>'
  245. def to_python(self, value):
  246. if re.search(NUMERIC_EXPANSION_PATTERN, value):
  247. return list(expand_numeric_pattern(value))
  248. return [value]
  249. class ExpandableIPAddressField(forms.CharField):
  250. """
  251. A field which allows for expansion of IP address ranges
  252. 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']
  253. """
  254. def __init__(self, *args, **kwargs):
  255. super(ExpandableIPAddressField, self).__init__(*args, **kwargs)
  256. if not self.help_text:
  257. self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
  258. 'Example: <code>192.0.2.[1,5,100-254]/24</code>'
  259. def to_python(self, value):
  260. # Hackish address family detection but it's all we have to work with
  261. if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
  262. return list(expand_ipaddress_pattern(value, 4))
  263. elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
  264. return list(expand_ipaddress_pattern(value, 6))
  265. return [value]
  266. class CommentField(forms.CharField):
  267. """
  268. A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text.
  269. """
  270. widget = forms.Textarea
  271. default_label = 'Comments'
  272. # TODO: Port GFM syntax cheat sheet to internal documentation
  273. default_helptext = '<i class="fa fa-info-circle"></i> '\
  274. '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
  275. 'GitHub-Flavored Markdown</a> syntax is supported'
  276. def __init__(self, *args, **kwargs):
  277. required = kwargs.pop('required', False)
  278. label = kwargs.pop('label', self.default_label)
  279. help_text = kwargs.pop('help_text', self.default_helptext)
  280. super(CommentField, self).__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
  281. class FlexibleModelChoiceField(forms.ModelChoiceField):
  282. """
  283. Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`.
  284. """
  285. def to_python(self, value):
  286. if value in self.empty_values:
  287. return None
  288. try:
  289. if not self.to_field_name:
  290. key = 'pk'
  291. elif re.match('^\{\d+\}$', value):
  292. key = 'pk'
  293. value = value.strip('{}')
  294. else:
  295. key = self.to_field_name
  296. value = self.queryset.get(**{key: value})
  297. except (ValueError, TypeError, self.queryset.model.DoesNotExist):
  298. raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice')
  299. return value
  300. class ChainedModelChoiceField(forms.ModelChoiceField):
  301. """
  302. A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary
  303. mapping of model fields to peer fields within the form. For example:
  304. country1 = forms.ModelChoiceField(queryset=Country.objects.all())
  305. city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'}
  306. The queryset of the `city1` field will be modified as
  307. .filter(country=<value>)
  308. where <value> is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.)
  309. """
  310. def __init__(self, chains=None, *args, **kwargs):
  311. self.chains = chains
  312. super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
  313. class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
  314. """
  315. See ChainedModelChoiceField
  316. """
  317. def __init__(self, chains=None, *args, **kwargs):
  318. self.chains = chains
  319. super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs)
  320. class SlugField(forms.SlugField):
  321. def __init__(self, slug_source='name', *args, **kwargs):
  322. label = kwargs.pop('label', "Slug")
  323. help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
  324. super(SlugField, self).__init__(label=label, help_text=help_text, *args, **kwargs)
  325. self.widget.attrs['slug-source'] = slug_source
  326. class FilterChoiceFieldMixin(object):
  327. iterator = forms.models.ModelChoiceIterator
  328. def __init__(self, null_option=None, *args, **kwargs):
  329. self.null_option = null_option
  330. if 'required' not in kwargs:
  331. kwargs['required'] = False
  332. if 'widget' not in kwargs:
  333. kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
  334. super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs)
  335. def label_from_instance(self, obj):
  336. label = super(FilterChoiceFieldMixin, self).label_from_instance(obj)
  337. if hasattr(obj, 'filter_count'):
  338. return '{} ({})'.format(label, obj.filter_count)
  339. return label
  340. def _get_choices(self):
  341. if hasattr(self, '_choices'):
  342. return self._choices
  343. if self.null_option is not None:
  344. return itertools.chain([self.null_option], self.iterator(self))
  345. return self.iterator(self)
  346. choices = property(_get_choices, forms.ChoiceField._set_choices)
  347. class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
  348. pass
  349. class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
  350. pass
  351. class LaxURLField(forms.URLField):
  352. """
  353. Modifies Django's built-in URLField in two ways:
  354. 1) Allow any valid scheme per RFC 3986 section 3.1
  355. 2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)
  356. """
  357. default_validators = [EnhancedURLValidator()]
  358. #
  359. # Forms
  360. #
  361. class BootstrapMixin(forms.BaseForm):
  362. def __init__(self, *args, **kwargs):
  363. super(BootstrapMixin, self).__init__(*args, **kwargs)
  364. exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect]
  365. for field_name, field in self.fields.items():
  366. if field.widget.__class__ not in exempt_widgets:
  367. css = field.widget.attrs.get('class', '')
  368. field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
  369. if field.required and not isinstance(field.widget, forms.FileInput):
  370. field.widget.attrs['required'] = 'required'
  371. if 'placeholder' not in field.widget.attrs:
  372. field.widget.attrs['placeholder'] = field.label
  373. class ChainedFieldsMixin(forms.BaseForm):
  374. """
  375. Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields.
  376. """
  377. def __init__(self, *args, **kwargs):
  378. super(ChainedFieldsMixin, self).__init__(*args, **kwargs)
  379. for field_name, field in self.fields.items():
  380. if isinstance(field, ChainedModelChoiceField):
  381. filters_dict = {}
  382. for (db_field, parent_field) in field.chains:
  383. if self.is_bound and parent_field in self.data:
  384. filters_dict[db_field] = self.data[parent_field] or None
  385. elif self.initial.get(parent_field):
  386. filters_dict[db_field] = self.initial[parent_field]
  387. elif self.fields[parent_field].widget.attrs.get('nullable'):
  388. filters_dict[db_field] = None
  389. else:
  390. break
  391. if filters_dict:
  392. field.queryset = field.queryset.filter(**filters_dict)
  393. elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
  394. obj = getattr(self.instance, field_name)
  395. if obj is not None:
  396. field.queryset = field.queryset.filter(pk=obj.pk)
  397. else:
  398. field.queryset = field.queryset.none()
  399. elif not self.is_bound:
  400. field.queryset = field.queryset.none()
  401. class ReturnURLForm(forms.Form):
  402. """
  403. Provides a hidden return URL field to control where the user is directed after the form is submitted.
  404. """
  405. return_url = forms.CharField(required=False, widget=forms.HiddenInput())
  406. class ConfirmationForm(BootstrapMixin, ReturnURLForm):
  407. """
  408. A generic confirmation form. The form is not valid unless the confirm field is checked.
  409. """
  410. confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
  411. class ComponentForm(BootstrapMixin, forms.Form):
  412. """
  413. Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
  414. """
  415. def __init__(self, parent, *args, **kwargs):
  416. self.parent = parent
  417. super(ComponentForm, self).__init__(*args, **kwargs)
  418. class BulkEditForm(forms.Form):
  419. def __init__(self, model, *args, **kwargs):
  420. super(BulkEditForm, self).__init__(*args, **kwargs)
  421. self.model = model
  422. # Copy any nullable fields defined in Meta
  423. if hasattr(self.Meta, 'nullable_fields'):
  424. self.nullable_fields = [field for field in self.Meta.nullable_fields]
  425. else:
  426. self.nullable_fields = []