import re from django import forms from django.core.urlresolvers import reverse_lazy from django.utils.encoding import force_text from django.utils.html import format_html from django.utils.safestring import mark_safe EXPANSION_PATTERN = '\[(\d+-\d+)\]' def expand_pattern(string): """ Expand a numeric pattern into a list of strings. Examples: 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3'] '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'] """ lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1) x, y = pattern.split('-') for i in range(int(x), int(y) + 1): if remnant: for string in expand_pattern(remnant): yield "{}{}{}".format(lead, i, string) else: yield "{}{}".format(lead, i) # # Widgets # class SmallTextarea(forms.Textarea): pass class SelectWithDisabled(forms.Select): """ Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include 'label' (string) and 'disabled' (boolean). """ def render_option(self, selected_choices, option_value, option_label): # Determine if option has been selected option_value = force_text(option_value) if option_value in selected_choices: selected_html = mark_safe(' selected="selected"') if not self.allow_multiple_selected: # Only allow for a single selection. selected_choices.remove(option_value) else: selected_html = '' # Determine if option has been disabled option_disabled = False exempt_value = force_text(self.attrs.get('exempt', None)) if isinstance(option_label, dict): option_disabled = option_label['disabled'] if option_value != exempt_value else False option_label = option_label['label'] disabled_html = ' disabled="disabled"' if option_disabled else '' return format_html('', option_value, selected_html, disabled_html, force_text(option_label)) class APISelect(SelectWithDisabled): """ A select widget populated via an API call :param api_url: API URL :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. """ def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs): super(APISelect, self).__init__(*args, **kwargs) self.attrs['class'] = 'api-select' self.attrs['api-url'] = api_url if display_field: self.attrs['display-field'] = display_field if disabled_indicator: self.attrs['disabled-indicator'] = disabled_indicator class Livesearch(forms.TextInput): """ A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search :param query_key: The name of the parameter to query against :param query_url: The name of the API URL to query :param field_to_update: The name of the "real" form field whose value is being set :param obj_label: The field to use as the option label (optional) """ def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs): super(Livesearch, self).__init__(*args, **kwargs) self.attrs = { 'data-key': query_key, 'data-source': reverse_lazy(query_url), 'data-field': field_to_update, } if obj_label: self.attrs['data-label'] = obj_label # # Form fields # class CSVDataField(forms.CharField): """ A field for comma-separated values (CSV) """ csv_form = None def __init__(self, csv_form, *args, **kwargs): self.csv_form = csv_form self.columns = self.csv_form().fields.keys() self.widget = forms.Textarea super(CSVDataField, self).__init__(*args, **kwargs) self.strip = False if not self.label: self.label = 'CSV Data' if not self.help_text: self.help_text = 'Enter one line per record in CSV format.' def to_python(self, value): # Return a list of dictionaries, each representing an individual record records = [] for i, row in enumerate(value.split('\n'), start=1): if row.strip(): values = row.strip().split(',') if len(values) < len(self.columns): raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})" .format(i, len(values), len(self.columns))) elif len(values) > len(self.columns): raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})" .format(i, len(values), len(self.columns))) record = dict(zip(self.columns, values)) records.append(record) return records class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] """ def __init__(self, *args, **kwargs): super(ExpandableNameField, self).__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Numeric ranges are supported for bulk creation.' def to_python(self, value): if re.search(EXPANSION_PATTERN, value): return list(expand_pattern(value)) return [value] class CommentField(forms.CharField): """ A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text. """ widget = forms.Textarea # TODO: Port GFM syntax cheat sheet to internal documentation default_helptext = ' '\ ''\ 'GitHub-Flavored Markdown syntax is supported' def __init__(self, *args, **kwargs): required = kwargs.pop('required', False) help_text = kwargs.pop('help_text', self.default_helptext) super(CommentField, self).__init__(required=required, help_text=help_text, *args, **kwargs) class FlexibleModelChoiceField(forms.ModelChoiceField): """ Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`. """ def to_python(self, value): if value in self.empty_values: return None try: if not self.to_field_name: key = 'pk' elif re.match('^\{\d+\}$', value): key = 'pk' value = value.strip('{}') else: key = self.to_field_name value = self.queryset.get(**{key: value}) except (ValueError, TypeError, self.queryset.model.DoesNotExist): raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice') return value # # Forms # class BootstrapMixin(forms.BaseForm): def __init__(self, *args, **kwargs): super(BootstrapMixin, self).__init__(*args, **kwargs) for field_name, field in self.fields.items(): if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]: try: field.widget.attrs['class'] += ' form-control' except KeyError: field.widget.attrs['class'] = 'form-control' if field.required: field.widget.attrs['required'] = 'required' field.widget.attrs['placeholder'] = field.label class ConfirmationForm(forms.Form, BootstrapMixin): confirm = forms.BooleanField(required=True) class BulkImportForm(forms.Form): def clean(self): records = self.cleaned_data.get('csv') if not records: return obj_list = [] for i, record in enumerate(records, start=1): obj_form = self.fields['csv'].csv_form(data=record) if obj_form.is_valid(): obj = obj_form.save(commit=False) obj_list.append(obj) else: for field, errors in obj_form.errors.items(): for e in errors: self.add_error('csv', "Record {} ({}): {}".format(i, field, e)) self.cleaned_data['csv'] = obj_list