123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- from __future__ import unicode_literals
- import csv
- import itertools
- import re
- from mptt.forms import TreeNodeMultipleChoiceField
- from django import forms
- from django.conf import settings
- from django.urls import reverse_lazy
- from .validators import EnhancedURLValidator
- COLOR_CHOICES = (
- ('aa1409', 'Dark red'),
- ('f44336', 'Red'),
- ('e91e63', 'Pink'),
- ('ff66ff', 'Fuschia'),
- ('9c27b0', 'Purple'),
- ('673ab7', 'Dark purple'),
- ('3f51b5', 'Indigo'),
- ('2196f3', 'Blue'),
- ('03a9f4', 'Light blue'),
- ('00bcd4', 'Cyan'),
- ('009688', 'Teal'),
- ('2f6a31', 'Dark green'),
- ('4caf50', 'Green'),
- ('8bc34a', 'Light green'),
- ('cddc39', 'Lime'),
- ('ffeb3b', 'Yellow'),
- ('ffc107', 'Amber'),
- ('ff9800', 'Orange'),
- ('ff5722', 'Dark orange'),
- ('795548', 'Brown'),
- ('c0c0c0', 'Light grey'),
- ('9e9e9e', 'Grey'),
- ('607d8b', 'Dark grey'),
- ('111111', 'Black'),
- )
- NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
- IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
- IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
- def parse_numeric_range(string, base=10):
- """
- Expand a numeric range (continuous or not) into a decimal or
- hexadecimal list, as specified by the base parameter
- '0-3,5' => [0, 1, 2, 3, 5]
- '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
- """
- values = list()
- for dash_range in string.split(','):
- try:
- begin, end = dash_range.split('-')
- except ValueError:
- begin, end = dash_range, dash_range
- begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
- values.extend(range(begin, end))
- return list(set(values))
- def expand_numeric_pattern(string):
- """
- Expand a numeric pattern into a list of strings. Examples:
- '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']
- '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']
- """
- lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
- parsed_range = parse_numeric_range(pattern)
- for i in parsed_range:
- if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
- for string in expand_numeric_pattern(remnant):
- yield "{}{}{}".format(lead, i, string)
- else:
- yield "{}{}{}".format(lead, i, remnant)
- def expand_ipaddress_pattern(string, family):
- """
- Expand an IP address pattern into a list of strings. Examples:
- '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']
- '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']
- """
- if family not in [4, 6]:
- raise Exception("Invalid IP address family: {}".format(family))
- if family == 4:
- regex = IP4_EXPANSION_PATTERN
- base = 10
- else:
- regex = IP6_EXPANSION_PATTERN
- base = 16
- lead, pattern, remnant = re.split(regex, string, maxsplit=1)
- parsed_range = parse_numeric_range(pattern, base)
- for i in parsed_range:
- if re.search(regex, remnant):
- for string in expand_ipaddress_pattern(remnant, family):
- yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string])
- else:
- yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant])
- def add_blank_choice(choices):
- """
- Add a blank choice to the beginning of a choices list.
- """
- return ((None, '---------'),) + tuple(choices)
- #
- # Widgets
- #
- class SmallTextarea(forms.Textarea):
- pass
- class ColorSelect(forms.Select):
- """
- Extends the built-in Select widget to colorize each <option>.
- """
- option_template_name = 'colorselect_option.html'
- def __init__(self, *args, **kwargs):
- kwargs['choices'] = COLOR_CHOICES
- super(ColorSelect, self).__init__(*args, **kwargs)
- class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
- def __init__(self, *args, **kwargs):
- super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs)
- # Override the built-in choice labels
- self.choices = (
- ('1', '---------'),
- ('2', 'Yes'),
- ('3', 'No'),
- )
- 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).
- """
- option_template_name = 'selectwithdisabled_option.html'
- class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
- """
- MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
- """
- def __init__(self, *args, **kwargs):
- self.delimiter = kwargs.pop('delimiter', ',')
- super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs)
- def optgroups(self, name, value, attrs=None):
- # Split the delimited string of values into a list
- value = value[0].split(self.delimiter)
- return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
- def value_from_datadict(self, data, files, name):
- # Condense the list of selected choices into a delimited string
- data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name)
- return self.delimiter.join(data)
- 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'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
- 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 CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping
- column headers to values. Each dictionary represents an individual record.
- """
- widget = forms.Textarea
- def __init__(self, fields, required_fields=[], *args, **kwargs):
- self.fields = fields
- self.required_fields = required_fields
- super(CSVDataField, self).__init__(*args, **kwargs)
- self.strip = False
- if not self.label:
- self.label = 'CSV Data'
- if not self.initial:
- self.initial = ','.join(required_fields) + '\n'
- if not self.help_text:
- self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \
- 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \
- 'in double quotes.'
- def to_python(self, value):
- # Python 2's csv module has problems with Unicode
- if not isinstance(value, str):
- value = value.encode('utf-8')
- records = []
- reader = csv.reader(value.splitlines())
- # Consume and valdiate the first line of CSV data as column headers
- headers = next(reader)
- for f in self.required_fields:
- if f not in headers:
- raise forms.ValidationError('Required column header "{}" not found.'.format(f))
- for f in headers:
- if f not in self.fields:
- raise forms.ValidationError('Unexpected column header "{}" found.'.format(f))
- # Parse CSV data
- for i, row in enumerate(reader, start=1):
- if row:
- if len(row) != len(headers):
- raise forms.ValidationError(
- "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row))
- )
- row = [col.strip() for col in row]
- record = dict(zip(headers, row))
- records.append(record)
- return records
- class CSVChoiceField(forms.ChoiceField):
- """
- Invert the provided set of choices to take the human-friendly label as input, and return the database value.
- """
- def __init__(self, choices, *args, **kwargs):
- super(CSVChoiceField, self).__init__(choices, *args, **kwargs)
- self.choices = [(label, label) for value, label in choices]
- self.choice_values = {label: value for value, label in choices}
- def clean(self, value):
- value = super(CSVChoiceField, self).clean(value)
- if not value:
- return None
- if value not in self.choice_values:
- raise forms.ValidationError("Invalid choice: {}".format(value))
- return self.choice_values[value]
- 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.<br />'\
- 'Example: <code>ge-0/0/[0-23,25,30]</code>'
- def to_python(self, value):
- if re.search(NUMERIC_EXPANSION_PATTERN, value):
- return list(expand_numeric_pattern(value))
- return [value]
- class ExpandableIPAddressField(forms.CharField):
- """
- A field which allows for expansion of IP address ranges
- 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']
- """
- def __init__(self, *args, **kwargs):
- super(ExpandableIPAddressField, self).__init__(*args, **kwargs)
- if not self.help_text:
- self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
- 'Example: <code>192.0.2.[1,5,100-254]/24</code>'
- def to_python(self, value):
- # Hackish address family detection but it's all we have to work with
- if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
- return list(expand_ipaddress_pattern(value, 4))
- elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
- return list(expand_ipaddress_pattern(value, 6))
- 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
- default_label = 'Comments'
- # TODO: Port GFM syntax cheat sheet to internal documentation
- default_helptext = '<i class="fa fa-info-circle"></i> '\
- '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
- 'GitHub-Flavored Markdown</a> syntax is supported'
- def __init__(self, *args, **kwargs):
- required = kwargs.pop('required', False)
- label = kwargs.pop('label', self.default_label)
- help_text = kwargs.pop('help_text', self.default_helptext)
- super(CommentField, self).__init__(required=required, label=label, 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
- class ChainedModelChoiceField(forms.ModelChoiceField):
- """
- A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary
- mapping of model fields to peer fields within the form. For example:
- country1 = forms.ModelChoiceField(queryset=Country.objects.all())
- city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'}
- The queryset of the `city1` field will be modified as
- .filter(country=<value>)
- where <value> is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.)
- """
- def __init__(self, chains=None, *args, **kwargs):
- self.chains = chains
- super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
- class SlugField(forms.SlugField):
- def __init__(self, slug_source='name', *args, **kwargs):
- label = kwargs.pop('label', "Slug")
- help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
- super(SlugField, self).__init__(label=label, help_text=help_text, *args, **kwargs)
- self.widget.attrs['slug-source'] = slug_source
- class FilterChoiceFieldMixin(object):
- iterator = forms.models.ModelChoiceIterator
- def __init__(self, null_option=None, *args, **kwargs):
- self.null_option = null_option
- if 'required' not in kwargs:
- kwargs['required'] = False
- if 'widget' not in kwargs:
- kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
- super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs)
- def label_from_instance(self, obj):
- label = super(FilterChoiceFieldMixin, self).label_from_instance(obj)
- if hasattr(obj, 'filter_count'):
- return '{} ({})'.format(label, obj.filter_count)
- return label
- def _get_choices(self):
- if hasattr(self, '_choices'):
- return self._choices
- if self.null_option is not None:
- return itertools.chain([self.null_option], self.iterator(self))
- return self.iterator(self)
- choices = property(_get_choices, forms.ChoiceField._set_choices)
- class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
- pass
- class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
- pass
- class LaxURLField(forms.URLField):
- """
- Modifies Django's built-in URLField in two ways:
- 1) Allow any valid scheme per RFC 3986 section 3.1
- 2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)
- """
- default_validators = [EnhancedURLValidator()]
- #
- # Forms
- #
- class BootstrapMixin(forms.BaseForm):
- def __init__(self, *args, **kwargs):
- super(BootstrapMixin, self).__init__(*args, **kwargs)
- exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect]
- for field_name, field in self.fields.items():
- if field.widget.__class__ not in exempt_widgets:
- css = field.widget.attrs.get('class', '')
- field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip()
- if field.required and not isinstance(field.widget, forms.FileInput):
- field.widget.attrs['required'] = 'required'
- if 'placeholder' not in field.widget.attrs:
- field.widget.attrs['placeholder'] = field.label
- class ChainedFieldsMixin(forms.BaseForm):
- """
- Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields.
- """
- def __init__(self, *args, **kwargs):
- super(ChainedFieldsMixin, self).__init__(*args, **kwargs)
- for field_name, field in self.fields.items():
- if isinstance(field, ChainedModelChoiceField):
- filters_dict = {}
- for (db_field, parent_field) in field.chains:
- if self.is_bound and parent_field in self.data:
- filters_dict[db_field] = self.data[parent_field] or None
- elif self.initial.get(parent_field):
- filters_dict[db_field] = self.initial[parent_field]
- elif self.fields[parent_field].widget.attrs.get('nullable'):
- filters_dict[db_field] = None
- else:
- break
- if filters_dict:
- field.queryset = field.queryset.filter(**filters_dict)
- elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
- obj = getattr(self.instance, field_name)
- if obj is not None:
- field.queryset = field.queryset.filter(pk=obj.pk)
- else:
- field.queryset = field.queryset.none()
- elif not self.is_bound:
- field.queryset = field.queryset.none()
- class ReturnURLForm(forms.Form):
- """
- Provides a hidden return URL field to control where the user is directed after the form is submitted.
- """
- return_url = forms.CharField(required=False, widget=forms.HiddenInput())
- class ConfirmationForm(BootstrapMixin, ReturnURLForm):
- """
- A generic confirmation form. The form is not valid unless the confirm field is checked.
- """
- confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
- class BulkEditForm(forms.Form):
- def __init__(self, model, *args, **kwargs):
- super(BulkEditForm, self).__init__(*args, **kwargs)
- self.model = model
- # Copy any nullable fields defined in Meta
- if hasattr(self.Meta, 'nullable_fields'):
- self.nullable_fields = [field for field in self.Meta.nullable_fields]
- else:
- self.nullable_fields = []
|