forms.py 9.2 KB

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