forms.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import csv
  2. import itertools
  3. import re
  4. from mptt.forms import TreeNodeMultipleChoiceField
  5. from django import forms
  6. from django.conf import settings
  7. from django.core.urlresolvers import reverse_lazy
  8. from django.core.validators import URLValidator
  9. COLOR_CHOICES = (
  10. ('aa1409', 'Dark red'),
  11. ('f44336', 'Red'),
  12. ('e91e63', 'Pink'),
  13. ('ff66ff', 'Fuschia'),
  14. ('9c27b0', 'Purple'),
  15. ('673ab7', 'Dark purple'),
  16. ('3f51b5', 'Indigo'),
  17. ('2196f3', 'Blue'),
  18. ('03a9f4', 'Light blue'),
  19. ('00bcd4', 'Cyan'),
  20. ('009688', 'Teal'),
  21. ('2f6a31', 'Dark green'),
  22. ('4caf50', 'Green'),
  23. ('8bc34a', 'Light green'),
  24. ('cddc39', 'Lime'),
  25. ('ffeb3b', 'Yellow'),
  26. ('ffc107', 'Amber'),
  27. ('ff9800', 'Orange'),
  28. ('ff5722', 'Dark orange'),
  29. ('795548', 'Brown'),
  30. ('c0c0c0', 'Light grey'),
  31. ('9e9e9e', 'Grey'),
  32. ('607d8b', 'Dark grey'),
  33. ('111111', 'Black'),
  34. )
  35. NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
  36. IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
  37. IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
  38. def parse_numeric_range(string, base=10):
  39. """
  40. Expand a numeric range (continuous or not) into a decimal or
  41. hexadecimal list, as specified by the base parameter
  42. '0-3,5' => [0, 1, 2, 3, 5]
  43. '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
  44. """
  45. values = list()
  46. for dash_range in string.split(','):
  47. try:
  48. begin, end = dash_range.split('-')
  49. except ValueError:
  50. begin, end = dash_range, dash_range
  51. begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
  52. values.extend(range(begin, end))
  53. return list(set(values))
  54. def expand_numeric_pattern(string):
  55. """
  56. Expand a numeric pattern into a list of strings. Examples:
  57. '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']
  58. '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']
  59. """
  60. lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
  61. parsed_range = parse_numeric_range(pattern)
  62. for i in parsed_range:
  63. if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
  64. for string in expand_numeric_pattern(remnant):
  65. yield "{}{}{}".format(lead, i, string)
  66. else:
  67. yield "{}{}{}".format(lead, i, remnant)
  68. def expand_ipaddress_pattern(string, family):
  69. """
  70. Expand an IP address pattern into a list of strings. Examples:
  71. '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']
  72. '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']
  73. """
  74. if family not in [4, 6]:
  75. raise Exception("Invalid IP address family: {}".format(family))
  76. if family == 4:
  77. regex = IP4_EXPANSION_PATTERN
  78. base = 10
  79. else:
  80. regex = IP6_EXPANSION_PATTERN
  81. base = 16
  82. lead, pattern, remnant = re.split(regex, string, maxsplit=1)
  83. parsed_range = parse_numeric_range(pattern, base)
  84. for i in parsed_range:
  85. if re.search(regex, remnant):
  86. for string in expand_ipaddress_pattern(remnant, family):
  87. yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string])
  88. else:
  89. yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant])
  90. def add_blank_choice(choices):
  91. """
  92. Add a blank choice to the beginning of a choices list.
  93. """
  94. return ((None, '---------'),) + tuple(choices)
  95. #
  96. # Widgets
  97. #
  98. class SmallTextarea(forms.Textarea):
  99. pass
  100. class ColorSelect(forms.Select):
  101. """
  102. Extends the built-in Select widget to colorize each <option>.
  103. """
  104. option_template_name = 'colorselect_option.html'
  105. def __init__(self, *args, **kwargs):
  106. kwargs['choices'] = COLOR_CHOICES
  107. super(ColorSelect, self).__init__(*args, **kwargs)
  108. class SelectWithDisabled(forms.Select):
  109. """
  110. Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
  111. 'label' (string) and 'disabled' (boolean).
  112. """
  113. option_template_name = 'selectwithdisabled_option.html'
  114. class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
  115. """
  116. MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
  117. """
  118. def __init__(self, *args, **kwargs):
  119. self.delimiter = kwargs.pop('delimiter', ',')
  120. super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs)
  121. def optgroups(self, name, value, attrs=None):
  122. # Split the delimited string of values into a list
  123. value = value[0].split(self.delimiter)
  124. return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
  125. def value_from_datadict(self, data, files, name):
  126. # Condense the list of selected choices into a delimited string
  127. data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name)
  128. return self.delimiter.join(data)
  129. class APISelect(SelectWithDisabled):
  130. """
  131. A select widget populated via an API call
  132. :param api_url: API URL
  133. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
  134. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
  135. """
  136. def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs):
  137. super(APISelect, self).__init__(*args, **kwargs)
  138. self.attrs['class'] = 'api-select'
  139. self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  140. if display_field:
  141. self.attrs['display-field'] = display_field
  142. if disabled_indicator:
  143. self.attrs['disabled-indicator'] = disabled_indicator
  144. class Livesearch(forms.TextInput):
  145. """
  146. A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
  147. :param query_key: The name of the parameter to query against
  148. :param query_url: The name of the API URL to query
  149. :param field_to_update: The name of the "real" form field whose value is being set
  150. :param obj_label: The field to use as the option label (optional)
  151. """
  152. def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs):
  153. super(Livesearch, self).__init__(*args, **kwargs)
  154. self.attrs = {
  155. 'data-key': query_key,
  156. 'data-source': reverse_lazy(query_url),
  157. 'data-field': field_to_update,
  158. }
  159. if obj_label:
  160. self.attrs['data-label'] = obj_label
  161. #
  162. # Form fields
  163. #
  164. class CSVDataField(forms.CharField):
  165. """
  166. A field for comma-separated values (CSV). Values containing commas should be encased within double quotes. Example:
  167. '"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
  168. """
  169. csv_form = None
  170. widget = forms.Textarea
  171. def __init__(self, csv_form, *args, **kwargs):
  172. self.csv_form = csv_form
  173. self.columns = self.csv_form().fields.keys()
  174. super(CSVDataField, self).__init__(*args, **kwargs)
  175. self.strip = False
  176. if not self.label:
  177. self.label = 'CSV Data'
  178. if not self.help_text:
  179. self.help_text = 'Enter one line per record in CSV format.'
  180. def to_python(self, value):
  181. """
  182. Return a list of dictionaries, each representing an individual record
  183. """
  184. # Python 2's csv module has problems with Unicode
  185. if not isinstance(value, str):
  186. value = value.encode('utf-8')
  187. records = []
  188. reader = csv.reader(value.splitlines())
  189. for i, row in enumerate(reader, start=1):
  190. if row:
  191. if len(row) < len(self.columns):
  192. raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})"
  193. .format(i, len(row), len(self.columns)))
  194. elif len(row) > len(self.columns):
  195. raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})"
  196. .format(i, len(row), len(self.columns)))
  197. row = [col.strip() for col in row]
  198. record = dict(zip(self.columns, row))
  199. records.append(record)
  200. return records
  201. class ExpandableNameField(forms.CharField):
  202. """
  203. A field which allows for numeric range expansion
  204. Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
  205. """
  206. def __init__(self, *args, **kwargs):
  207. super(ExpandableNameField, self).__init__(*args, **kwargs)
  208. if not self.help_text:
  209. self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
  210. 'Example: <code>ge-0/0/[0-23,25,30]</code>'
  211. def to_python(self, value):
  212. if re.search(NUMERIC_EXPANSION_PATTERN, value):
  213. return list(expand_numeric_pattern(value))
  214. return [value]
  215. class ExpandableIPAddressField(forms.CharField):
  216. """
  217. A field which allows for expansion of IP address ranges
  218. 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']
  219. """
  220. def __init__(self, *args, **kwargs):
  221. super(ExpandableIPAddressField, self).__init__(*args, **kwargs)
  222. if not self.help_text:
  223. self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
  224. 'Example: <code>192.0.2.[1,5,100-254]/24</code>'
  225. def to_python(self, value):
  226. # Hackish address family detection but it's all we have to work with
  227. if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
  228. return list(expand_ipaddress_pattern(value, 4))
  229. elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
  230. return list(expand_ipaddress_pattern(value, 6))
  231. return [value]
  232. class CommentField(forms.CharField):
  233. """
  234. A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text.
  235. """
  236. widget = forms.Textarea
  237. default_label = 'Comments'
  238. # TODO: Port GFM syntax cheat sheet to internal documentation
  239. default_helptext = '<i class="fa fa-info-circle"></i> '\
  240. '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
  241. 'GitHub-Flavored Markdown</a> syntax is supported'
  242. def __init__(self, *args, **kwargs):
  243. required = kwargs.pop('required', False)
  244. label = kwargs.pop('label', self.default_label)
  245. help_text = kwargs.pop('help_text', self.default_helptext)
  246. super(CommentField, self).__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
  247. class FlexibleModelChoiceField(forms.ModelChoiceField):
  248. """
  249. Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`.
  250. """
  251. def to_python(self, value):
  252. if value in self.empty_values:
  253. return None
  254. try:
  255. if not self.to_field_name:
  256. key = 'pk'
  257. elif re.match('^\{\d+\}$', value):
  258. key = 'pk'
  259. value = value.strip('{}')
  260. else:
  261. key = self.to_field_name
  262. value = self.queryset.get(**{key: value})
  263. except (ValueError, TypeError, self.queryset.model.DoesNotExist):
  264. raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice')
  265. return value
  266. class SlugField(forms.SlugField):
  267. def __init__(self, slug_source='name', *args, **kwargs):
  268. label = kwargs.pop('label', "Slug")
  269. help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
  270. super(SlugField, self).__init__(label=label, help_text=help_text, *args, **kwargs)
  271. self.widget.attrs['slug-source'] = slug_source
  272. class FilterChoiceFieldMixin(object):
  273. iterator = forms.models.ModelChoiceIterator
  274. def __init__(self, null_option=None, *args, **kwargs):
  275. self.null_option = null_option
  276. if 'required' not in kwargs:
  277. kwargs['required'] = False
  278. if 'widget' not in kwargs:
  279. kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
  280. super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs)
  281. def label_from_instance(self, obj):
  282. label = super(FilterChoiceFieldMixin, self).label_from_instance(obj)
  283. if hasattr(obj, 'filter_count'):
  284. return u'{} ({})'.format(label, obj.filter_count)
  285. return label
  286. def _get_choices(self):
  287. if hasattr(self, '_choices'):
  288. return self._choices
  289. if self.null_option is not None:
  290. return itertools.chain([self.null_option], self.iterator(self))
  291. return self.iterator(self)
  292. choices = property(_get_choices, forms.ChoiceField._set_choices)
  293. class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
  294. pass
  295. class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
  296. pass
  297. class LaxURLField(forms.URLField):
  298. """
  299. Custom URLField which allows any valid URL scheme
  300. """
  301. class AnyURLScheme(object):
  302. # A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
  303. def __contains__(self, item):
  304. if not item or not re.match('^[a-z][0-9a-z+\-.]*$', item.lower()):
  305. return False
  306. return True
  307. default_validators = [URLValidator(schemes=AnyURLScheme())]
  308. #
  309. # Forms
  310. #
  311. class BootstrapMixin(forms.BaseForm):
  312. def __init__(self, *args, **kwargs):
  313. super(BootstrapMixin, self).__init__(*args, **kwargs)
  314. exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect]
  315. for field_name, field in self.fields.items():
  316. if field.widget.__class__ not in exempt_widgets:
  317. css = field.widget.attrs.get('class', '')
  318. field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
  319. if field.required:
  320. field.widget.attrs['required'] = 'required'
  321. if 'placeholder' not in field.widget.attrs:
  322. field.widget.attrs['placeholder'] = field.label
  323. class ConfirmationForm(BootstrapMixin, forms.Form):
  324. """
  325. A generic confirmation form. The form is not valid unless the confirm field is checked. An optional return_url can
  326. be specified to direct the user to a specific URL after the action has been taken.
  327. """
  328. confirm = forms.BooleanField(required=True)
  329. return_url = forms.CharField(required=False, widget=forms.HiddenInput())
  330. class BulkEditForm(forms.Form):
  331. def __init__(self, model, *args, **kwargs):
  332. super(BulkEditForm, self).__init__(*args, **kwargs)
  333. self.model = model
  334. # Copy any nullable fields defined in Meta
  335. if hasattr(self.Meta, 'nullable_fields'):
  336. self.nullable_fields = [field for field in self.Meta.nullable_fields]
  337. else:
  338. self.nullable_fields = []
  339. class BulkImportForm(forms.Form):
  340. def clean(self):
  341. records = self.cleaned_data.get('csv')
  342. if not records:
  343. return
  344. obj_list = []
  345. for i, record in enumerate(records, start=1):
  346. obj_form = self.fields['csv'].csv_form(data=record)
  347. if obj_form.is_valid():
  348. obj = obj_form.save(commit=False)
  349. obj_list.append(obj)
  350. else:
  351. for field, errors in obj_form.errors.items():
  352. for e in errors:
  353. if field == '__all__':
  354. self.add_error('csv', "Record {}: {}".format(i, e))
  355. else:
  356. self.add_error('csv', "Record {} ({}): {}".format(i, field, e))
  357. self.cleaned_data['csv'] = obj_list