diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
deleted file mode 100644
index 0e1768387..000000000
--- a/netbox/utilities/forms.py
+++ /dev/null
@@ -1,843 +0,0 @@
-import csv
-import json
-import re
-from io import StringIO
-
-import django_filters
-import yaml
-from django import forms
-from django.conf import settings
-from django.contrib.postgres.forms import SimpleArrayField
-from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
-from django.core.exceptions import MultipleObjectsReturned
-from django.db.models import Count
-from django.forms import BoundField
-from django.forms.models import fields_for_model
-from django.urls import reverse
-
-from utilities.querysets import RestrictedQuerySet
-from .choices import ColorChoices, unpack_grouped_choices
-from .validators import EnhancedURLValidator
-
-NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
-ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
-IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
-IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
-BOOLEAN_WITH_BLANK_CHOICES = (
- ('', '---------'),
- ('True', 'Yes'),
- ('False', 'No'),
-)
-
-
-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 parse_alphanumeric_range(string):
- """
- Expand an alphanumeric range (continuous or not) into a list.
- 'a-d,f' => [a, b, c, d, f]
- '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
- """
- values = []
- for dash_range in string.split(','):
- try:
- begin, end = dash_range.split('-')
- vals = begin + end
- # Break out of loop if there's an invalid pattern to return an error
- if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
- return []
- except ValueError:
- begin, end = dash_range, dash_range
- if begin.isdigit() and end.isdigit():
- for n in list(range(int(begin), int(end) + 1)):
- values.append(n)
- else:
- # Value-based
- if begin == end:
- values.append(begin)
- # Range-based
- else:
- # Not a valid range (more than a single character)
- if not len(begin) == len(end) == 1:
- raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range))
- for n in list(range(ord(begin), ord(end) + 1)):
- values.append(chr(n))
- return values
-
-
-def expand_alphanumeric_pattern(string):
- """
- Expand an alphabetic pattern into a list of strings.
- """
- lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
- parsed_range = parse_alphanumeric_range(pattern)
- for i in parsed_range:
- if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
- for string in expand_alphanumeric_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]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/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)
-
-
-def form_from_model(model, fields):
- """
- Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
- for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
- are marked as not required.
- """
- form_fields = fields_for_model(model, fields=fields)
- for field in form_fields.values():
- field.required = False
-
- return type('FormFromModel', (forms.Form,), form_fields)
-
-
-def restrict_form_fields(form, user, action='view'):
- """
- Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects
- as available choices.
- """
- for field in form.fields.values():
- if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet):
- field.queryset = field.queryset.restrict(user, action)
-
-
-#
-# Widgets
-#
-
-class SmallTextarea(forms.Textarea):
- """
- Subclass used for rendering a smaller textarea element.
- """
- pass
-
-
-class SlugWidget(forms.TextInput):
- """
- Subclass TextInput and add a slug regeneration button next to the form field.
- """
- template_name = 'widgets/sluginput.html'
-
-
-class ColorSelect(forms.Select):
- """
- Extends the built-in Select widget to colorize each
- This attribute can be used to reference the relevant API endpoint for a particular ContentType.
- """
- option_template_name = 'widgets/select_contenttype.html'
-
-
-class NumericArrayField(SimpleArrayField):
-
- def to_python(self, value):
- value = ','.join([str(n) for n in parse_numeric_range(value)])
- return super().to_python(value)
-
-
-class APISelect(SelectWithDisabled):
- """
- A select widget populated via an API call
-
- :param api_url: API endpoint URL. Required if not set automatically by the parent field.
- :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
- :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`.
- :param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
- :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the
- name of the filter-for field (child field) and the value is the name of the query param filter.
- :param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the
- condition is met. The condition is the dict key and is specified in the form `__`.
- If the provided field value is selected for the given field, the URL query param will be appended to
- the rendered URL. The value is the in the from `=`. This is useful in cases where
- a particular field value dictates an additional API filter.
- :param additional_query_params: Optional) A dict of query params to append to the API request. The key is the
- name of the query param and the value if the query param's value.
- :param null_option: If true, include the static null option in the selection list.
- """
- def __init__(
- self,
- api_url=None,
- display_field=None,
- value_field=None,
- disabled_indicator=None,
- filter_for=None,
- conditional_query_params=None,
- additional_query_params=None,
- null_option=False,
- full=False,
- *args,
- **kwargs
- ):
-
- super().__init__(*args, **kwargs)
-
- self.attrs['class'] = 'netbox-select2-api'
- if api_url:
- self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
- if full:
- self.attrs['data-full'] = full
- if display_field:
- self.attrs['display-field'] = display_field
- if value_field:
- self.attrs['value-field'] = value_field
- if disabled_indicator:
- self.attrs['disabled-indicator'] = disabled_indicator
- if filter_for:
- for key, value in filter_for.items():
- self.add_filter_for(key, value)
- if conditional_query_params:
- for key, value in conditional_query_params.items():
- self.add_conditional_query_param(key, value)
- if additional_query_params:
- for key, value in additional_query_params.items():
- self.add_additional_query_param(key, value)
- if null_option:
- self.attrs['data-null-option'] = 1
-
- def add_filter_for(self, name, value):
- """
- Add details for an additional query param in the form of a data-filter-for-* attribute.
-
- :param name: The name of the query param
- :param value: The value of the query param
- """
- self.attrs['data-filter-for-{}'.format(name)] = value
-
- def add_additional_query_param(self, name, value):
- """
- Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
-
- :param name: The name of the query param
- :param value: The value of the query param
- """
- key = 'data-additional-query-param-{}'.format(name)
-
- values = json.loads(self.attrs.get(key, '[]'))
- values.append(value)
-
- self.attrs[key] = json.dumps(values)
-
- def add_conditional_query_param(self, condition, value):
- """
- Add details for a URL query strings to append to the URL if the condition is met.
- The condition is specified in the form `__`.
-
- :param condition: The condition for the query param
- :param value: The value of the query param
- """
- self.attrs['data-conditional-query-param-{}'.format(condition)] = value
-
-
-class APISelectMultiple(APISelect, forms.SelectMultiple):
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.attrs['data-multiple'] = 1
-
-
-class DatePicker(forms.TextInput):
- """
- Date picker using Flatpickr.
- """
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.attrs['class'] = 'date-picker'
- self.attrs['placeholder'] = 'YYYY-MM-DD'
-
-
-class DateTimePicker(forms.TextInput):
- """
- DateTime picker using Flatpickr.
- """
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.attrs['class'] = 'datetime-picker'
- self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
-
-
-class TimePicker(forms.TextInput):
- """
- Time picker using Flatpickr.
- """
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.attrs['class'] = 'time-picker'
- self.attrs['placeholder'] = 'hh:mm:ss'
-
-
-#
-# Form fields
-#
-
-class CSVDataField(forms.CharField):
- """
- A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
- item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
- (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
-
- :param from_form: The form from which the field derives its validation rules.
- """
- widget = forms.Textarea
-
- def __init__(self, from_form, *args, **kwargs):
-
- form = from_form()
- self.model = form.Meta.model
- self.fields = form.fields
- self.required_fields = [
- name for name, field in form.fields.items() if field.required
- ]
-
- super().__init__(*args, **kwargs)
-
- self.strip = False
- if not self.label:
- self.label = ''
- if not self.initial:
- self.initial = ','.join(self.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):
-
- records = []
- reader = csv.reader(StringIO(value.strip()))
-
- # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
- # "to" field specifying how the related object is being referenced. For example, importing a Device might use a
- # `site.slug` header, to indicate the related site is being referenced by its slug.
- headers = {}
- for header in next(reader):
- if '.' in header:
- field, to_field = header.split('.', 1)
- headers[field] = to_field
- else:
- headers[header] = None
-
- # Parse CSV rows into a list of dictionaries mapped from the column headers.
- for i, row in enumerate(reader, start=1):
- if len(row) != len(headers):
- raise forms.ValidationError(
- f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
- )
- row = [col.strip() for col in row]
- record = dict(zip(headers.keys(), row))
- records.append(record)
-
- return headers, records
-
- def validate(self, value):
- headers, records = value
-
- # Validate provided column headers
- for field, to_field in headers.items():
- if field not in self.fields:
- raise forms.ValidationError(f'Unexpected column header "{field}" found.')
- if to_field and not hasattr(self.fields[field], 'to_field_name'):
- raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
- if to_field and not hasattr(self.fields[field].queryset.model, to_field):
- raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
-
- # Validate required fields
- for f in self.required_fields:
- if f not in headers:
- raise forms.ValidationError(f'Required column header "{f}" not found.')
-
- return value
-
-
-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().__init__(choices=choices, *args, **kwargs)
- self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
- self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)}
-
- def clean(self, value):
- value = super().clean(value)
- if not value:
- return ''
- if value not in self.choice_values:
- raise forms.ValidationError("Invalid choice: {}".format(value))
- return self.choice_values[value]
-
-
-class CSVModelChoiceField(forms.ModelChoiceField):
- """
- Provides additional validation for model choices entered as CSV data.
- """
- default_error_messages = {
- 'invalid_choice': 'Object not found.',
- }
-
- def to_python(self, value):
- try:
- return super().to_python(value)
- except MultipleObjectsReturned as e:
- raise forms.ValidationError(
- f'"{value}" is not a unique value for this field; multiple objects were found'
- )
-
-
-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().__init__(*args, **kwargs)
- if not self.help_text:
- self.help_text = """
- Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
- are not supported. Examples:
-
-
[ge,xe]-0/0/[0-9]
-
e[0-3][a-d,f]
-
- """
-
- def to_python(self, value):
- if not value:
- return ''
- if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
- return list(expand_alphanumeric_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().__init__(*args, **kwargs)
- if not self.help_text:
- self.help_text = 'Specify a numeric range to create multiple IPs. '\
- 'Example: 192.0.2.[1,5,100-254]/24'
-
- 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 Markdown rendering. Exists mostly just to add a standard help_text.
- """
- widget = forms.Textarea
- default_label = ''
- # TODO: Port Markdown cheat sheet to internal documentation
- default_helptext = ' '\
- ''\
- 'Markdown 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().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
-
-
-class SlugField(forms.SlugField):
- """
- Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
- """
- def __init__(self, slug_source='name', *args, **kwargs):
- label = kwargs.pop('label', "Slug")
- help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
- widget = kwargs.pop('widget', SlugWidget)
- super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
- self.widget.attrs['slug-source'] = slug_source
-
-
-class TagFilterField(forms.MultipleChoiceField):
- """
- A filter field for the tags of a model. Only the tags used by a model are displayed.
-
- :param model: The model of the filter
- """
- widget = StaticSelect2Multiple
-
- def __init__(self, model, *args, **kwargs):
- def get_choices():
- tags = model.tags.annotate(
- count=Count('extras_taggeditem_items')
- ).order_by('name')
- return [
- (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
- ]
-
- # Choices are fetched each time the form is initialized
- super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
-
-
-class DynamicModelChoiceMixin:
- filter = django_filters.ModelChoiceFilter
- widget = APISelect
-
- def get_bound_field(self, form, field_name):
- bound_field = BoundField(form, self, field_name)
-
- # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
- # will be populated on-demand via the APISelect widget.
- data = bound_field.value()
- if data:
- field_name = getattr(self, 'to_field_name') or 'pk'
- filter = self.filter(field_name=field_name)
- try:
- self.queryset = filter.filter(self.queryset, data)
- except TypeError:
- # Catch any error caused by invalid initial data passed from the user
- self.queryset = self.queryset.none()
- else:
- self.queryset = self.queryset.none()
-
- # Set the data URL on the APISelect widget (if not already set)
- widget = bound_field.field.widget
- if not widget.attrs.get('data-url'):
- app_label = self.queryset.model._meta.app_label
- model_name = self.queryset.model._meta.model_name
- data_url = reverse('{}-api:{}-list'.format(app_label, model_name))
- widget.attrs['data-url'] = data_url
-
- return bound_field
-
-
-class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
- """
- Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
- rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
- """
- pass
-
-
-class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
- """
- A multiple-choice version of DynamicModelChoiceField.
- """
- filter = django_filters.ModelMultipleChoiceFilter
- widget = APISelectMultiple
-
-
-class LaxURLField(forms.URLField):
- """
- Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
- (e.g. http://myserver/ is valid)
- """
- default_validators = [EnhancedURLValidator()]
-
-
-class JSONField(_JSONField):
- """
- Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
- """
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- if not self.help_text:
- self.help_text = 'Enter context data in JSON format.'
- self.widget.attrs['placeholder'] = ''
-
- def prepare_value(self, value):
- if isinstance(value, InvalidJSONInput):
- return value
- if value is None:
- return ''
- return json.dumps(value, sort_keys=True, indent=4)
-
-
-#
-# Forms
-#
-
-class BootstrapMixin(forms.BaseForm):
- """
- Add the base Bootstrap CSS classes to form elements.
- """
- def __init__(self, *args, **kwargs):
- super().__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 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):
- """
- Base form for editing multiple objects in bulk
- """
- def __init__(self, model, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.model = model
- self.nullable_fields = []
-
- # Copy any nullable fields defined in Meta
- if hasattr(self.Meta, 'nullable_fields'):
- self.nullable_fields = self.Meta.nullable_fields
-
-
-class BulkRenameForm(forms.Form):
- """
- An extendable form to be used for renaming objects in bulk.
- """
- find = forms.CharField()
- replace = forms.CharField()
- use_regex = forms.BooleanField(
- required=False,
- initial=True,
- label='Use regular expressions'
- )
-
- def clean(self):
-
- # Validate regular expression in "find" field
- if self.cleaned_data['use_regex']:
- try:
- re.compile(self.cleaned_data['find'])
- except re.error:
- raise forms.ValidationError({
- 'find': "Invalid regular expression"
- })
-
-
-class CSVModelForm(forms.ModelForm):
- """
- ModelForm used for the import of objects in CSV format.
- """
- def __init__(self, *args, headers=None, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Modify the model form to accommodate any customized to_field_name properties
- if headers:
- for field, to_field in headers.items():
- if to_field is not None:
- self.fields[field].to_field_name = to_field
-
-
-class ImportForm(BootstrapMixin, forms.Form):
- """
- Generic form for creating an object from JSON/YAML data
- """
- data = forms.CharField(
- widget=forms.Textarea,
- help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported."
- )
- format = forms.ChoiceField(
- choices=(
- ('json', 'JSON'),
- ('yaml', 'YAML')
- ),
- initial='yaml'
- )
-
- def clean(self):
-
- data = self.cleaned_data['data']
- format = self.cleaned_data['format']
-
- # Process JSON/YAML data
- if format == 'json':
- try:
- self.cleaned_data['data'] = json.loads(data)
- # Check for multiple JSON objects
- if type(self.cleaned_data['data']) is not dict:
- raise forms.ValidationError({
- 'data': "Import is limited to one object at a time."
- })
- except json.decoder.JSONDecodeError as err:
- raise forms.ValidationError({
- 'data': "Invalid JSON data: {}".format(err)
- })
- else:
- # Check for multiple YAML documents
- if '\n---' in data:
- raise forms.ValidationError({
- 'data': "Import is limited to one object at a time."
- })
- try:
- self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
- except yaml.error.YAMLError as err:
- raise forms.ValidationError({
- 'data': "Invalid YAML data: {}".format(err)
- })
-
-
-class TableConfigForm(BootstrapMixin, forms.Form):
- """
- Form for configuring user's table preferences.
- """
- columns = forms.MultipleChoiceField(
- choices=[],
- widget=forms.SelectMultiple(
- attrs={'size': 10}
- ),
- help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display."
- )
-
- def __init__(self, table, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Initialize columns field based on table attributes
- self.fields['columns'].choices = table.configurable_columns
- self.fields['columns'].initial = table.visible_columns
diff --git a/netbox/utilities/forms/__init__.py b/netbox/utilities/forms/__init__.py
new file mode 100644
index 000000000..ce958a99e
--- /dev/null
+++ b/netbox/utilities/forms/__init__.py
@@ -0,0 +1,5 @@
+from .constants import *
+from .fields import *
+from .forms import *
+from .utils import *
+from .widgets import *
diff --git a/netbox/utilities/forms/constants.py b/netbox/utilities/forms/constants.py
new file mode 100644
index 000000000..624ad5dac
--- /dev/null
+++ b/netbox/utilities/forms/constants.py
@@ -0,0 +1,14 @@
+# String expansion patterns
+NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
+ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
+
+# IP address expansion patterns
+IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
+IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
+
+# Boolean widget choices
+BOOLEAN_WITH_BLANK_CHOICES = (
+ ('', '---------'),
+ ('True', 'Yes'),
+ ('False', 'No'),
+)
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
new file mode 100644
index 000000000..b4948a419
--- /dev/null
+++ b/netbox/utilities/forms/fields.py
@@ -0,0 +1,317 @@
+import csv
+import json
+import re
+from io import StringIO
+
+import django_filters
+from django import forms
+from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
+from django.core.exceptions import MultipleObjectsReturned
+from django.db.models import Count
+from django.forms import BoundField
+from django.urls import reverse
+
+from utilities.choices import unpack_grouped_choices
+from utilities.validators import EnhancedURLValidator
+from . import widgets
+from .constants import *
+from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
+
+__all__ = (
+ 'CommentField',
+ 'CSVChoiceField',
+ 'CSVDataField',
+ 'CSVModelChoiceField',
+ 'DynamicModelChoiceField',
+ 'DynamicModelMultipleChoiceField',
+ 'ExpandableIPAddressField',
+ 'ExpandableNameField',
+ 'JSONField',
+ 'LaxURLField',
+ 'SlugField',
+ 'TagFilterField',
+)
+
+
+class CSVDataField(forms.CharField):
+ """
+ A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
+ item is a dictionary of column headers, mapping field names to the attribute by which they match a related object
+ (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data.
+
+ :param from_form: The form from which the field derives its validation rules.
+ """
+ widget = forms.Textarea
+
+ def __init__(self, from_form, *args, **kwargs):
+
+ form = from_form()
+ self.model = form.Meta.model
+ self.fields = form.fields
+ self.required_fields = [
+ name for name, field in form.fields.items() if field.required
+ ]
+
+ super().__init__(*args, **kwargs)
+
+ self.strip = False
+ if not self.label:
+ self.label = ''
+ if not self.initial:
+ self.initial = ','.join(self.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):
+
+ records = []
+ reader = csv.reader(StringIO(value.strip()))
+
+ # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
+ # "to" field specifying how the related object is being referenced. For example, importing a Device might use a
+ # `site.slug` header, to indicate the related site is being referenced by its slug.
+ headers = {}
+ for header in next(reader):
+ if '.' in header:
+ field, to_field = header.split('.', 1)
+ headers[field] = to_field
+ else:
+ headers[header] = None
+
+ # Parse CSV rows into a list of dictionaries mapped from the column headers.
+ for i, row in enumerate(reader, start=1):
+ if len(row) != len(headers):
+ raise forms.ValidationError(
+ f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
+ )
+ row = [col.strip() for col in row]
+ record = dict(zip(headers.keys(), row))
+ records.append(record)
+
+ return headers, records
+
+ def validate(self, value):
+ headers, records = value
+
+ # Validate provided column headers
+ for field, to_field in headers.items():
+ if field not in self.fields:
+ raise forms.ValidationError(f'Unexpected column header "{field}" found.')
+ if to_field and not hasattr(self.fields[field], 'to_field_name'):
+ raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
+ if to_field and not hasattr(self.fields[field].queryset.model, to_field):
+ raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
+
+ # Validate required fields
+ for f in self.required_fields:
+ if f not in headers:
+ raise forms.ValidationError(f'Required column header "{f}" not found.')
+
+ return value
+
+
+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().__init__(choices=choices, *args, **kwargs)
+ self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)]
+ self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)}
+
+ def clean(self, value):
+ value = super().clean(value)
+ if not value:
+ return ''
+ if value not in self.choice_values:
+ raise forms.ValidationError("Invalid choice: {}".format(value))
+ return self.choice_values[value]
+
+
+class CSVModelChoiceField(forms.ModelChoiceField):
+ """
+ Provides additional validation for model choices entered as CSV data.
+ """
+ default_error_messages = {
+ 'invalid_choice': 'Object not found.',
+ }
+
+ def to_python(self, value):
+ try:
+ return super().to_python(value)
+ except MultipleObjectsReturned as e:
+ raise forms.ValidationError(
+ f'"{value}" is not a unique value for this field; multiple objects were found'
+ )
+
+
+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().__init__(*args, **kwargs)
+ if not self.help_text:
+ self.help_text = """
+ Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
+ are not supported. Examples:
+
+
[ge,xe]-0/0/[0-9]
+
e[0-3][a-d,f]
+
+ """
+
+ def to_python(self, value):
+ if not value:
+ return ''
+ if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
+ return list(expand_alphanumeric_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().__init__(*args, **kwargs)
+ if not self.help_text:
+ self.help_text = 'Specify a numeric range to create multiple IPs. '\
+ 'Example: 192.0.2.[1,5,100-254]/24'
+
+ 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 Markdown rendering. Exists mostly just to add a standard help_text.
+ """
+ widget = forms.Textarea
+ default_label = ''
+ # TODO: Port Markdown cheat sheet to internal documentation
+ default_helptext = ' '\
+ ''\
+ 'Markdown 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().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
+
+
+class SlugField(forms.SlugField):
+ """
+ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
+ """
+ def __init__(self, slug_source='name', *args, **kwargs):
+ label = kwargs.pop('label', "Slug")
+ help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
+ widget = kwargs.pop('widget', widgets.SlugWidget)
+ super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
+ self.widget.attrs['slug-source'] = slug_source
+
+
+class TagFilterField(forms.MultipleChoiceField):
+ """
+ A filter field for the tags of a model. Only the tags used by a model are displayed.
+
+ :param model: The model of the filter
+ """
+ widget = widgets.StaticSelect2Multiple
+
+ def __init__(self, model, *args, **kwargs):
+ def get_choices():
+ tags = model.tags.annotate(
+ count=Count('extras_taggeditem_items')
+ ).order_by('name')
+ return [
+ (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
+ ]
+
+ # Choices are fetched each time the form is initialized
+ super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
+
+
+class DynamicModelChoiceMixin:
+ filter = django_filters.ModelChoiceFilter
+ widget = widgets.APISelect
+
+ def get_bound_field(self, form, field_name):
+ bound_field = BoundField(form, self, field_name)
+
+ # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
+ # will be populated on-demand via the APISelect widget.
+ data = bound_field.value()
+ if data:
+ field_name = getattr(self, 'to_field_name') or 'pk'
+ filter = self.filter(field_name=field_name)
+ try:
+ self.queryset = filter.filter(self.queryset, data)
+ except TypeError:
+ # Catch any error caused by invalid initial data passed from the user
+ self.queryset = self.queryset.none()
+ else:
+ self.queryset = self.queryset.none()
+
+ # Set the data URL on the APISelect widget (if not already set)
+ widget = bound_field.field.widget
+ if not widget.attrs.get('data-url'):
+ app_label = self.queryset.model._meta.app_label
+ model_name = self.queryset.model._meta.model_name
+ data_url = reverse('{}-api:{}-list'.format(app_label, model_name))
+ widget.attrs['data-url'] = data_url
+
+ return bound_field
+
+
+class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
+ """
+ Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
+ rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
+ """
+ pass
+
+
+class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
+ """
+ A multiple-choice version of DynamicModelChoiceField.
+ """
+ filter = django_filters.ModelMultipleChoiceFilter
+ widget = widgets.APISelectMultiple
+
+
+class LaxURLField(forms.URLField):
+ """
+ Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
+ (e.g. http://myserver/ is valid)
+ """
+ default_validators = [EnhancedURLValidator()]
+
+
+class JSONField(_JSONField):
+ """
+ Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not self.help_text:
+ self.help_text = 'Enter context data in JSON format.'
+ self.widget.attrs['placeholder'] = ''
+
+ def prepare_value(self, value):
+ if isinstance(value, InvalidJSONInput):
+ return value
+ if value is None:
+ return ''
+ return json.dumps(value, sort_keys=True, indent=4)
diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py
new file mode 100644
index 000000000..bc68f29f6
--- /dev/null
+++ b/netbox/utilities/forms/forms.py
@@ -0,0 +1,175 @@
+import json
+import re
+
+import yaml
+from django import forms
+
+
+__all__ = (
+ 'BootstrapMixin',
+ 'BulkEditForm',
+ 'BulkRenameForm',
+ 'ConfirmationForm',
+ 'CSVModelForm',
+ 'ImportForm',
+ 'ReturnURLForm',
+ 'TableConfigForm',
+)
+
+
+class BootstrapMixin(forms.BaseForm):
+ """
+ Add the base Bootstrap CSS classes to form elements.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__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 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):
+ """
+ Base form for editing multiple objects in bulk
+ """
+ def __init__(self, model, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.model = model
+ self.nullable_fields = []
+
+ # Copy any nullable fields defined in Meta
+ if hasattr(self.Meta, 'nullable_fields'):
+ self.nullable_fields = self.Meta.nullable_fields
+
+
+class BulkRenameForm(forms.Form):
+ """
+ An extendable form to be used for renaming objects in bulk.
+ """
+ find = forms.CharField()
+ replace = forms.CharField()
+ use_regex = forms.BooleanField(
+ required=False,
+ initial=True,
+ label='Use regular expressions'
+ )
+
+ def clean(self):
+
+ # Validate regular expression in "find" field
+ if self.cleaned_data['use_regex']:
+ try:
+ re.compile(self.cleaned_data['find'])
+ except re.error:
+ raise forms.ValidationError({
+ 'find': "Invalid regular expression"
+ })
+
+
+class CSVModelForm(forms.ModelForm):
+ """
+ ModelForm used for the import of objects in CSV format.
+ """
+ def __init__(self, *args, headers=None, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Modify the model form to accommodate any customized to_field_name properties
+ if headers:
+ for field, to_field in headers.items():
+ if to_field is not None:
+ self.fields[field].to_field_name = to_field
+
+
+class ImportForm(BootstrapMixin, forms.Form):
+ """
+ Generic form for creating an object from JSON/YAML data
+ """
+ data = forms.CharField(
+ widget=forms.Textarea,
+ help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported."
+ )
+ format = forms.ChoiceField(
+ choices=(
+ ('json', 'JSON'),
+ ('yaml', 'YAML')
+ ),
+ initial='yaml'
+ )
+
+ def clean(self):
+
+ data = self.cleaned_data['data']
+ format = self.cleaned_data['format']
+
+ # Process JSON/YAML data
+ if format == 'json':
+ try:
+ self.cleaned_data['data'] = json.loads(data)
+ # Check for multiple JSON objects
+ if type(self.cleaned_data['data']) is not dict:
+ raise forms.ValidationError({
+ 'data': "Import is limited to one object at a time."
+ })
+ except json.decoder.JSONDecodeError as err:
+ raise forms.ValidationError({
+ 'data': "Invalid JSON data: {}".format(err)
+ })
+ else:
+ # Check for multiple YAML documents
+ if '\n---' in data:
+ raise forms.ValidationError({
+ 'data': "Import is limited to one object at a time."
+ })
+ try:
+ self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
+ except yaml.error.YAMLError as err:
+ raise forms.ValidationError({
+ 'data': "Invalid YAML data: {}".format(err)
+ })
+
+
+class TableConfigForm(BootstrapMixin, forms.Form):
+ """
+ Form for configuring user's table preferences.
+ """
+ columns = forms.MultipleChoiceField(
+ choices=[],
+ widget=forms.SelectMultiple(
+ attrs={'size': 10}
+ ),
+ help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display."
+ )
+
+ def __init__(self, table, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Initialize columns field based on table attributes
+ self.fields['columns'].choices = table.configurable_columns
+ self.fields['columns'].initial = table.visible_columns
diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py
new file mode 100644
index 000000000..dc001be1a
--- /dev/null
+++ b/netbox/utilities/forms/utils.py
@@ -0,0 +1,136 @@
+import re
+
+from django import forms
+from django.forms.models import fields_for_model
+
+from utilities.querysets import RestrictedQuerySet
+from .constants import *
+
+__all__ = (
+ 'add_blank_choice',
+ 'expand_alphanumeric_pattern',
+ 'expand_ipaddress_pattern',
+ 'form_from_model',
+ 'parse_alphanumeric_range',
+ 'parse_numeric_range',
+ 'restrict_form_fields',
+)
+
+
+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 parse_alphanumeric_range(string):
+ """
+ Expand an alphanumeric range (continuous or not) into a list.
+ 'a-d,f' => [a, b, c, d, f]
+ '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
+ """
+ values = []
+ for dash_range in string.split(','):
+ try:
+ begin, end = dash_range.split('-')
+ vals = begin + end
+ # Break out of loop if there's an invalid pattern to return an error
+ if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
+ return []
+ except ValueError:
+ begin, end = dash_range, dash_range
+ if begin.isdigit() and end.isdigit():
+ for n in list(range(int(begin), int(end) + 1)):
+ values.append(n)
+ else:
+ # Value-based
+ if begin == end:
+ values.append(begin)
+ # Range-based
+ else:
+ # Not a valid range (more than a single character)
+ if not len(begin) == len(end) == 1:
+ raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range))
+ for n in list(range(ord(begin), ord(end) + 1)):
+ values.append(chr(n))
+ return values
+
+
+def expand_alphanumeric_pattern(string):
+ """
+ Expand an alphabetic pattern into a list of strings.
+ """
+ lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
+ parsed_range = parse_alphanumeric_range(pattern)
+ for i in parsed_range:
+ if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
+ for string in expand_alphanumeric_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]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/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)
+
+
+def form_from_model(model, fields):
+ """
+ Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
+ for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
+ are marked as not required.
+ """
+ form_fields = fields_for_model(model, fields=fields)
+ for field in form_fields.values():
+ field.required = False
+
+ return type('FormFromModel', (forms.Form,), form_fields)
+
+
+def restrict_form_fields(form, user, action='view'):
+ """
+ Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects
+ as available choices.
+ """
+ for field in form.fields.values():
+ if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet):
+ field.queryset = field.queryset.restrict(user, action)
diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py
new file mode 100644
index 000000000..1c9d654f3
--- /dev/null
+++ b/netbox/utilities/forms/widgets.py
@@ -0,0 +1,266 @@
+import json
+
+from django import forms
+from django.conf import settings
+from django.contrib.postgres.forms import SimpleArrayField
+
+from utilities.choices import ColorChoices
+from .utils import add_blank_choice, parse_numeric_range
+
+__all__ = (
+ 'APISelect',
+ 'APISelectMultiple',
+ 'BulkEditNullBooleanSelect',
+ 'ColorSelect',
+ 'ContentTypeSelect',
+ 'DatePicker',
+ 'DateTimePicker',
+ 'NumericArrayField',
+ 'SelectWithDisabled',
+ 'SelectWithPK',
+ 'SlugWidget',
+ 'SmallTextarea',
+ 'StaticSelect2',
+ 'StaticSelect2Multiple',
+ 'TimePicker',
+)
+
+
+class SmallTextarea(forms.Textarea):
+ """
+ Subclass used for rendering a smaller textarea element.
+ """
+ pass
+
+
+class SlugWidget(forms.TextInput):
+ """
+ Subclass TextInput and add a slug regeneration button next to the form field.
+ """
+ template_name = 'widgets/sluginput.html'
+
+
+class ColorSelect(forms.Select):
+ """
+ Extends the built-in Select widget to colorize each
+ This attribute can be used to reference the relevant API endpoint for a particular ContentType.
+ """
+ option_template_name = 'widgets/select_contenttype.html'
+
+
+class NumericArrayField(SimpleArrayField):
+
+ def to_python(self, value):
+ value = ','.join([str(n) for n in parse_numeric_range(value)])
+ return super().to_python(value)
+
+
+class APISelect(SelectWithDisabled):
+ """
+ A select widget populated via an API call
+
+ :param api_url: API endpoint URL. Required if not set automatically by the parent field.
+ :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
+ :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`.
+ :param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
+ :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the
+ name of the filter-for field (child field) and the value is the name of the query param filter.
+ :param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the
+ condition is met. The condition is the dict key and is specified in the form `__`.
+ If the provided field value is selected for the given field, the URL query param will be appended to
+ the rendered URL. The value is the in the from `=`. This is useful in cases where
+ a particular field value dictates an additional API filter.
+ :param additional_query_params: Optional) A dict of query params to append to the API request. The key is the
+ name of the query param and the value if the query param's value.
+ :param null_option: If true, include the static null option in the selection list.
+ """
+ def __init__(
+ self,
+ api_url=None,
+ display_field=None,
+ value_field=None,
+ disabled_indicator=None,
+ filter_for=None,
+ conditional_query_params=None,
+ additional_query_params=None,
+ null_option=False,
+ full=False,
+ *args,
+ **kwargs
+ ):
+
+ super().__init__(*args, **kwargs)
+
+ self.attrs['class'] = 'netbox-select2-api'
+ if api_url:
+ self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
+ if full:
+ self.attrs['data-full'] = full
+ if display_field:
+ self.attrs['display-field'] = display_field
+ if value_field:
+ self.attrs['value-field'] = value_field
+ if disabled_indicator:
+ self.attrs['disabled-indicator'] = disabled_indicator
+ if filter_for:
+ for key, value in filter_for.items():
+ self.add_filter_for(key, value)
+ if conditional_query_params:
+ for key, value in conditional_query_params.items():
+ self.add_conditional_query_param(key, value)
+ if additional_query_params:
+ for key, value in additional_query_params.items():
+ self.add_additional_query_param(key, value)
+ if null_option:
+ self.attrs['data-null-option'] = 1
+
+ def add_filter_for(self, name, value):
+ """
+ Add details for an additional query param in the form of a data-filter-for-* attribute.
+
+ :param name: The name of the query param
+ :param value: The value of the query param
+ """
+ self.attrs['data-filter-for-{}'.format(name)] = value
+
+ def add_additional_query_param(self, name, value):
+ """
+ Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
+
+ :param name: The name of the query param
+ :param value: The value of the query param
+ """
+ key = 'data-additional-query-param-{}'.format(name)
+
+ values = json.loads(self.attrs.get(key, '[]'))
+ values.append(value)
+
+ self.attrs[key] = json.dumps(values)
+
+ def add_conditional_query_param(self, condition, value):
+ """
+ Add details for a URL query strings to append to the URL if the condition is met.
+ The condition is specified in the form `__`.
+
+ :param condition: The condition for the query param
+ :param value: The value of the query param
+ """
+ self.attrs['data-conditional-query-param-{}'.format(condition)] = value
+
+
+class APISelectMultiple(APISelect, forms.SelectMultiple):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.attrs['data-multiple'] = 1
+
+
+class DatePicker(forms.TextInput):
+ """
+ Date picker using Flatpickr.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.attrs['class'] = 'date-picker'
+ self.attrs['placeholder'] = 'YYYY-MM-DD'
+
+
+class DateTimePicker(forms.TextInput):
+ """
+ DateTime picker using Flatpickr.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.attrs['class'] = 'datetime-picker'
+ self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
+
+
+class TimePicker(forms.TextInput):
+ """
+ Time picker using Flatpickr.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.attrs['class'] = 'time-picker'
+ self.attrs['placeholder'] = 'hh:mm:ss'
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
index d6af27b93..dcd15872b 100644
--- a/netbox/utilities/tests/test_forms.py
+++ b/netbox/utilities/tests/test_forms.py
@@ -2,8 +2,8 @@ from django import forms
from django.test import TestCase
from ipam.forms import IPAddressCSVForm
-from ipam.models import VRF
-from utilities.forms import *
+from utilities.forms.fields import CSVDataField
+from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
class ExpandIPAddress(TestCase):