forms.py 11 KB

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