forms.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import csv
  2. import itertools
  3. import re
  4. from django import forms
  5. from django.conf import settings
  6. from django.core.urlresolvers import reverse_lazy
  7. from django.core.validators import URLValidator
  8. from django.utils.encoding import force_text
  9. from django.utils.html import format_html
  10. from django.utils.safestring import mark_safe
  11. EXPANSION_PATTERN = '\[(\d+-\d+)\]'
  12. def expand_pattern(string):
  13. """
  14. Expand a numeric pattern into a list of strings. Examples:
  15. 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
  16. 'xe-0/[0-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']
  17. """
  18. lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1)
  19. x, y = pattern.split('-')
  20. for i in range(int(x), int(y) + 1):
  21. if re.search(EXPANSION_PATTERN, remnant):
  22. for string in expand_pattern(remnant):
  23. yield "{}{}{}".format(lead, i, string)
  24. else:
  25. yield "{}{}{}".format(lead, i, remnant)
  26. def add_blank_choice(choices):
  27. """
  28. Add a blank choice to the beginning of a choices list.
  29. """
  30. return ((None, '---------'),) + choices
  31. #
  32. # Widgets
  33. #
  34. class SmallTextarea(forms.Textarea):
  35. pass
  36. class SelectWithDisabled(forms.Select):
  37. """
  38. Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
  39. 'label' (string) and 'disabled' (boolean).
  40. """
  41. def render_option(self, selected_choices, option_value, option_label):
  42. # Determine if option has been selected
  43. option_value = force_text(option_value)
  44. if option_value in selected_choices:
  45. selected_html = mark_safe(' selected="selected"')
  46. if not self.allow_multiple_selected:
  47. # Only allow for a single selection.
  48. selected_choices.remove(option_value)
  49. else:
  50. selected_html = ''
  51. # Determine if option has been disabled
  52. option_disabled = False
  53. exempt_value = force_text(self.attrs.get('exempt', None))
  54. if isinstance(option_label, dict):
  55. option_disabled = option_label['disabled'] if option_value != exempt_value else False
  56. option_label = option_label['label']
  57. disabled_html = ' disabled="disabled"' if option_disabled else ''
  58. return format_html(u'<option value="{}"{}{}>{}</option>',
  59. option_value,
  60. selected_html,
  61. disabled_html,
  62. force_text(option_label))
  63. class APISelect(SelectWithDisabled):
  64. """
  65. A select widget populated via an API call
  66. :param api_url: API URL
  67. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
  68. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
  69. """
  70. def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs):
  71. super(APISelect, self).__init__(*args, **kwargs)
  72. self.attrs['class'] = 'api-select'
  73. self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
  74. if display_field:
  75. self.attrs['display-field'] = display_field
  76. if disabled_indicator:
  77. self.attrs['disabled-indicator'] = disabled_indicator
  78. class Livesearch(forms.TextInput):
  79. """
  80. A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
  81. :param query_key: The name of the parameter to query against
  82. :param query_url: The name of the API URL to query
  83. :param field_to_update: The name of the "real" form field whose value is being set
  84. :param obj_label: The field to use as the option label (optional)
  85. """
  86. def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs):
  87. super(Livesearch, self).__init__(*args, **kwargs)
  88. self.attrs = {
  89. 'data-key': query_key,
  90. 'data-source': reverse_lazy(query_url),
  91. 'data-field': field_to_update,
  92. }
  93. if obj_label:
  94. self.attrs['data-label'] = obj_label
  95. #
  96. # Form fields
  97. #
  98. class CSVDataField(forms.CharField):
  99. """
  100. A field for comma-separated values (CSV). Values containing commas should be encased within double quotes. Example:
  101. '"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
  102. """
  103. csv_form = None
  104. widget = forms.Textarea
  105. def __init__(self, csv_form, *args, **kwargs):
  106. self.csv_form = csv_form
  107. self.columns = self.csv_form().fields.keys()
  108. super(CSVDataField, self).__init__(*args, **kwargs)
  109. self.strip = False
  110. if not self.label:
  111. self.label = 'CSV Data'
  112. if not self.help_text:
  113. self.help_text = 'Enter one line per record in CSV format.'
  114. def utf_8_encoder(self, unicode_csv_data):
  115. for line in unicode_csv_data:
  116. yield line.encode('utf-8')
  117. def to_python(self, value):
  118. # Return a list of dictionaries, each representing an individual record
  119. records = []
  120. reader = csv.reader(self.utf_8_encoder(value.splitlines()))
  121. for i, row in enumerate(reader, start=1):
  122. if row:
  123. if len(row) < len(self.columns):
  124. raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})"
  125. .format(i, len(row), len(self.columns)))
  126. elif len(row) > len(self.columns):
  127. raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})"
  128. .format(i, len(row), len(self.columns)))
  129. record = dict(zip(self.columns, row))
  130. records.append(record)
  131. return records
  132. class ExpandableNameField(forms.CharField):
  133. """
  134. A field which allows for numeric range expansion
  135. Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
  136. """
  137. def __init__(self, *args, **kwargs):
  138. super(ExpandableNameField, self).__init__(*args, **kwargs)
  139. if not self.help_text:
  140. self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
  141. 'Example: <code>ge-0/0/[0-47]</code>'
  142. def to_python(self, value):
  143. if re.search(EXPANSION_PATTERN, value):
  144. return list(expand_pattern(value))
  145. return [value]
  146. class CommentField(forms.CharField):
  147. """
  148. A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text.
  149. """
  150. widget = forms.Textarea
  151. # TODO: Port GFM syntax cheat sheet to internal documentation
  152. default_helptext = '<i class="fa fa-info-circle"></i> '\
  153. '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
  154. 'GitHub-Flavored Markdown</a> syntax is supported'
  155. def __init__(self, *args, **kwargs):
  156. required = kwargs.pop('required', False)
  157. help_text = kwargs.pop('help_text', self.default_helptext)
  158. super(CommentField, self).__init__(required=required, help_text=help_text, *args, **kwargs)
  159. class FlexibleModelChoiceField(forms.ModelChoiceField):
  160. """
  161. Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`.
  162. """
  163. def to_python(self, value):
  164. if value in self.empty_values:
  165. return None
  166. try:
  167. if not self.to_field_name:
  168. key = 'pk'
  169. elif re.match('^\{\d+\}$', value):
  170. key = 'pk'
  171. value = value.strip('{}')
  172. else:
  173. key = self.to_field_name
  174. value = self.queryset.get(**{key: value})
  175. except (ValueError, TypeError, self.queryset.model.DoesNotExist):
  176. raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice')
  177. return value
  178. class SlugField(forms.SlugField):
  179. def __init__(self, slug_source='name', *args, **kwargs):
  180. label = kwargs.pop('label', "Slug")
  181. help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
  182. super(SlugField, self).__init__(label=label, help_text=help_text, *args, **kwargs)
  183. self.widget.attrs['slug-source'] = slug_source
  184. class FilterChoiceField(forms.ModelMultipleChoiceField):
  185. iterator = forms.models.ModelChoiceIterator
  186. def __init__(self, null_option=None, *args, **kwargs):
  187. self.null_option = null_option
  188. if 'required' not in kwargs:
  189. kwargs['required'] = False
  190. if 'widget' not in kwargs:
  191. kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
  192. super(FilterChoiceField, self).__init__(*args, **kwargs)
  193. def label_from_instance(self, obj):
  194. if hasattr(obj, 'filter_count'):
  195. return u'{} ({})'.format(obj, obj.filter_count)
  196. return force_text(obj)
  197. def _get_choices(self):
  198. if hasattr(self, '_choices'):
  199. return self._choices
  200. if self.null_option is not None:
  201. return itertools.chain([self.null_option], self.iterator(self))
  202. return self.iterator(self)
  203. choices = property(_get_choices, forms.ChoiceField._set_choices)
  204. class LaxURLField(forms.URLField):
  205. """
  206. Custom URLField which allows any valid URL scheme
  207. """
  208. class AnyURLScheme(object):
  209. # A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
  210. def __contains__(self, item):
  211. if not item or not re.match('^[a-z][0-9a-z+\-.]*$', item.lower()):
  212. return False
  213. return True
  214. default_validators = [URLValidator(schemes=AnyURLScheme())]
  215. #
  216. # Forms
  217. #
  218. class BootstrapMixin(forms.BaseForm):
  219. def __init__(self, *args, **kwargs):
  220. super(BootstrapMixin, self).__init__(*args, **kwargs)
  221. for field_name, field in self.fields.items():
  222. if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]:
  223. try:
  224. field.widget.attrs['class'] += ' form-control'
  225. except KeyError:
  226. field.widget.attrs['class'] = 'form-control'
  227. if field.required:
  228. field.widget.attrs['required'] = 'required'
  229. if 'placeholder' not in field.widget.attrs:
  230. field.widget.attrs['placeholder'] = field.label
  231. class ConfirmationForm(forms.Form, BootstrapMixin):
  232. confirm = forms.BooleanField(required=True)
  233. class BulkEditForm(forms.Form):
  234. def __init__(self, *args, **kwargs):
  235. super(BulkEditForm, self).__init__(*args, **kwargs)
  236. self.nullable_fields = getattr(self.Meta, 'nullable_fields')
  237. class BulkImportForm(forms.Form):
  238. def clean(self):
  239. records = self.cleaned_data.get('csv')
  240. if not records:
  241. return
  242. obj_list = []
  243. for i, record in enumerate(records, start=1):
  244. obj_form = self.fields['csv'].csv_form(data=record)
  245. if obj_form.is_valid():
  246. obj = obj_form.save(commit=False)
  247. obj_list.append(obj)
  248. else:
  249. for field, errors in obj_form.errors.items():
  250. for e in errors:
  251. if field == '__all__':
  252. self.add_error('csv', "Record {}: {}".format(i, e))
  253. else:
  254. self.add_error('csv', "Record {} ({}): {}".format(i, field, e))
  255. self.cleaned_data['csv'] = obj_list