diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md
index 5af178194..c9c6cbde6 100644
--- a/docs/plugins/development/forms.md
+++ b/docs/plugins/development/forms.md
@@ -1,5 +1,7 @@
# Forms
+## Form Classes
+
NetBox provides several base form classes for use by plugins. These are documented below.
* `NetBoxModelForm`
@@ -8,3 +10,69 @@ NetBox provides several base form classes for use by plugins. These are document
* `NetBoxModelFilterSetForm`
### TODO: Include forms reference
+
+In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
+
+## General Purpose Fields
+
+::: utilities.forms.ColorField
+ selection:
+ members: false
+
+::: utilities.forms.CommentField
+ selection:
+ members: false
+
+::: utilities.forms.JSONField
+ selection:
+ members: false
+
+::: utilities.forms.MACAddressField
+ selection:
+ members: false
+
+::: utilities.forms.SlugField
+ selection:
+ members: false
+
+## Dynamic Object Fields
+
+::: utilities.forms.DynamicModelChoiceField
+ selection:
+ members: false
+
+::: utilities.forms.DynamicModelMultipleChoiceField
+ selection:
+ members: false
+
+## Content Type Fields
+
+::: utilities.forms.ContentTypeChoiceField
+ selection:
+ members: false
+
+::: utilities.forms.ContentTypeMultipleChoiceField
+ selection:
+ members: false
+
+## CSV Import Fields
+
+::: utilities.forms.CSVChoiceField
+ selection:
+ members: false
+
+::: utilities.forms.CSVMultipleChoiceField
+ selection:
+ members: false
+
+::: utilities.forms.CSVModelChoiceField
+ selection:
+ members: false
+
+::: utilities.forms.CSVContentTypeField
+ selection:
+ members: false
+
+::: utilities.forms.CSVMultipleContentTypeField
+ selection:
+ members: false
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
deleted file mode 100644
index ceca895c0..000000000
--- a/netbox/utilities/forms/fields.py
+++ /dev/null
@@ -1,526 +0,0 @@
-import csv
-import json
-import re
-from io import StringIO
-from netaddr import AddrFormatError, EUI
-
-import django_filters
-from django import forms
-from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
-from django.db.models import Count, Q
-from django.forms import BoundField
-from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
-from django.urls import reverse
-
-from utilities.choices import unpack_grouped_choices
-from utilities.utils import content_type_identifier, content_type_name
-from utilities.validators import EnhancedURLValidator
-from . import widgets
-from .constants import *
-from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv
-
-__all__ = (
- 'ColorField',
- 'CommentField',
- 'ContentTypeChoiceField',
- 'ContentTypeMultipleChoiceField',
- 'CSVChoiceField',
- 'CSVContentTypeField',
- 'CSVDataField',
- 'CSVFileField',
- 'CSVModelChoiceField',
- 'CSVMultipleChoiceField',
- 'CSVMultipleContentTypeField',
- 'CSVTypedChoiceField',
- 'DynamicModelChoiceField',
- 'DynamicModelMultipleChoiceField',
- 'ExpandableIPAddressField',
- 'ExpandableNameField',
- 'JSONField',
- 'LaxURLField',
- 'MACAddressField',
- 'SlugField',
- 'TagFilterField',
-)
-
-
-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 ColorField(forms.CharField):
- """
- A field which represents a color in hexadecimal RRGGBB format.
- """
- widget = widgets.ColorSelect
-
-
-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.StaticSelectMultiple
-
- 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 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)
-
-
-class MACAddressField(forms.Field):
- widget = forms.CharField
- default_error_messages = {
- 'invalid': 'MAC address must be in EUI-48 format',
- }
-
- def to_python(self, value):
- value = super().to_python(value)
-
- # Validate MAC address format
- try:
- value = EUI(value.strip())
- except AddrFormatError:
- raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
-
- return value
-
-
-#
-# Content type fields
-#
-
-class ContentTypeChoiceMixin:
-
- def __init__(self, queryset, *args, **kwargs):
- # Order ContentTypes by app_label
- queryset = queryset.order_by('app_label', 'model')
- super().__init__(queryset, *args, **kwargs)
-
- def label_from_instance(self, obj):
- try:
- return content_type_name(obj)
- except AttributeError:
- return super().label_from_instance(obj)
-
-
-class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
- widget = widgets.StaticSelect
-
-
-class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
- widget = widgets.StaticSelectMultiple
-
-
-#
-# CSV 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):
- reader = csv.reader(StringIO(value.strip()))
-
- return parse_csv(reader)
-
- def validate(self, value):
- headers, records = value
- validate_csv(headers, self.fields, self.required_fields)
-
- return value
-
-
-class CSVFileField(forms.FileField):
- """
- A FileField (rendered as a file input button) which accepts a file containing 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.
- """
-
- 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)
-
- def to_python(self, file):
- if file is None:
- return None
-
- csv_str = file.read().decode('utf-8').strip()
- reader = csv.reader(StringIO(csv_str))
- headers, records = parse_csv(reader)
-
- return headers, records
-
- def validate(self, value):
- if value is None:
- return None
-
- headers, records = value
- validate_csv(headers, self.fields, self.required_fields)
-
- return value
-
-
-class CSVChoicesMixin:
- STATIC_CHOICES = True
-
- def __init__(self, *, choices=(), **kwargs):
- super().__init__(choices=choices, **kwargs)
- self.choices = unpack_grouped_choices(choices)
-
-
-class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
- """
- A CSV field which accepts a single selection value.
- """
- pass
-
-
-class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
- """
- A CSV field which accepts multiple selection values.
- """
- def to_python(self, value):
- if not value:
- return []
- if not isinstance(value, str):
- raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
- return value.split(',')
-
-
-class CSVTypedChoiceField(forms.TypedChoiceField):
- STATIC_CHOICES = True
-
-
-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:
- raise forms.ValidationError(
- f'"{value}" is not a unique value for this field; multiple objects were found'
- )
-
-
-class CSVContentTypeField(CSVModelChoiceField):
- """
- Reference a ContentType in the form .
- """
- STATIC_CHOICES = True
-
- def prepare_value(self, value):
- return content_type_identifier(value)
-
- def to_python(self, value):
- if not value:
- return None
- try:
- app_label, model = value.split('.')
- except ValueError:
- raise forms.ValidationError(f'Object type must be specified as "."')
- try:
- return self.queryset.get(app_label=app_label, model=model)
- except ObjectDoesNotExist:
- raise forms.ValidationError(f'Invalid object type')
-
-
-class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
- STATIC_CHOICES = True
-
- # TODO: Improve validation of selected ContentTypes
- def prepare_value(self, value):
- if type(value) is str:
- ct_filter = Q()
- for name in value.split(','):
- app_label, model = name.split('.')
- ct_filter |= Q(app_label=app_label, model=model)
- return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
- return content_type_identifier(value)
-
-
-#
-# Expansion fields
-#
-
-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. Example: [ge,xe]-0/0/[0-9]
- """
-
- 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]
-
-
-#
-# Dynamic fields
-#
-
-class DynamicModelChoiceMixin:
- """
- :param query_params: A dictionary of additional key/value pairs to attach to the API request
- :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value
- :param null_option: The string used to represent a null selection (if any)
- :param disabled_indicator: The name of the field which, if populated, will disable selection of the
- choice (optional)
- :param str fetch_trigger: The event type which will cause the select element to
- fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
- """
- filter = django_filters.ModelChoiceFilter
- widget = widgets.APISelect
-
- def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
- fetch_trigger=None, empty_label=None, *args, **kwargs):
- self.query_params = query_params or {}
- self.initial_params = initial_params or {}
- self.null_option = null_option
- self.disabled_indicator = disabled_indicator
- self.fetch_trigger = fetch_trigger
-
- # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
- # by widget_attrs()
- self.to_field_name = kwargs.get('to_field_name')
- self.empty_option = empty_label or ""
-
- super().__init__(*args, **kwargs)
-
- def widget_attrs(self, widget):
- attrs = {
- 'data-empty-option': self.empty_option
- }
-
- # Set value-field attribute if the field specifies to_field_name
- if self.to_field_name:
- attrs['value-field'] = self.to_field_name
-
- # Set the string used to represent a null option
- if self.null_option is not None:
- attrs['data-null-option'] = self.null_option
-
- # Set the disabled indicator, if any
- if self.disabled_indicator is not None:
- attrs['disabled-indicator'] = self.disabled_indicator
-
- # Set the fetch trigger, if any.
- if self.fetch_trigger is not None:
- attrs['data-fetch-trigger'] = self.fetch_trigger
-
- # Attach any static query parameters
- if (len(self.query_params) > 0):
- widget.add_query_params(self.query_params)
-
- return attrs
-
- def get_bound_field(self, form, field_name):
- bound_field = BoundField(form, self, field_name)
-
- # Set initial value based on prescribed child fields (if not already set)
- if not self.initial and self.initial_params:
- filter_kwargs = {}
- for kwarg, child_field in self.initial_params.items():
- value = form.initial.get(child_field.lstrip('$'))
- if value:
- filter_kwargs[kwarg] = value
- if filter_kwargs:
- self.initial = self.queryset.filter(**filter_kwargs).first()
-
- # 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, ValueError):
- # 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.
- """
-
- def clean(self, value):
- """
- When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
- string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
- """
- if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
- return None
- return super().clean(value)
-
-
-class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
- """
- A multiple-choice version of DynamicModelChoiceField.
- """
- filter = django_filters.ModelMultipleChoiceFilter
- widget = widgets.APISelectMultiple
-
- def clean(self, value):
- """
- When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
- string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
- """
- if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
- value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
- return [None, *value]
- return super().clean(value)
diff --git a/netbox/utilities/forms/fields/__init__.py b/netbox/utilities/forms/fields/__init__.py
new file mode 100644
index 000000000..eacde0040
--- /dev/null
+++ b/netbox/utilities/forms/fields/__init__.py
@@ -0,0 +1,5 @@
+from .content_types import *
+from .csv import *
+from .dynamic import *
+from .expandable import *
+from .fields import *
diff --git a/netbox/utilities/forms/fields/content_types.py b/netbox/utilities/forms/fields/content_types.py
new file mode 100644
index 000000000..80861166c
--- /dev/null
+++ b/netbox/utilities/forms/fields/content_types.py
@@ -0,0 +1,37 @@
+from django import forms
+
+from utilities.forms import widgets
+from utilities.utils import content_type_name
+
+__all__ = (
+ 'ContentTypeChoiceField',
+ 'ContentTypeMultipleChoiceField',
+)
+
+
+class ContentTypeChoiceMixin:
+
+ def __init__(self, queryset, *args, **kwargs):
+ # Order ContentTypes by app_label
+ queryset = queryset.order_by('app_label', 'model')
+ super().__init__(queryset, *args, **kwargs)
+
+ def label_from_instance(self, obj):
+ try:
+ return content_type_name(obj)
+ except AttributeError:
+ return super().label_from_instance(obj)
+
+
+class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField):
+ """
+ Selection field for a single content type.
+ """
+ widget = widgets.StaticSelect
+
+
+class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField):
+ """
+ Selection field for one or more content types.
+ """
+ widget = widgets.StaticSelectMultiple
diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py
new file mode 100644
index 000000000..275c8084c
--- /dev/null
+++ b/netbox/utilities/forms/fields/csv.py
@@ -0,0 +1,193 @@
+import csv
+from io import StringIO
+
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
+from django.db.models import Q
+
+from utilities.choices import unpack_grouped_choices
+from utilities.forms.utils import parse_csv, validate_csv
+from utilities.utils import content_type_identifier
+
+__all__ = (
+ 'CSVChoiceField',
+ 'CSVContentTypeField',
+ 'CSVDataField',
+ 'CSVFileField',
+ 'CSVModelChoiceField',
+ 'CSVMultipleChoiceField',
+ 'CSVMultipleContentTypeField',
+ 'CSVTypedChoiceField',
+)
+
+
+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):
+ reader = csv.reader(StringIO(value.strip()))
+
+ return parse_csv(reader)
+
+ def validate(self, value):
+ headers, records = value
+ validate_csv(headers, self.fields, self.required_fields)
+
+ return value
+
+
+class CSVFileField(forms.FileField):
+ """
+ A FileField (rendered as a file input button) which accepts a file containing 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.
+ """
+
+ 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)
+
+ def to_python(self, file):
+ if file is None:
+ return None
+
+ csv_str = file.read().decode('utf-8').strip()
+ reader = csv.reader(StringIO(csv_str))
+ headers, records = parse_csv(reader)
+
+ return headers, records
+
+ def validate(self, value):
+ if value is None:
+ return None
+
+ headers, records = value
+ validate_csv(headers, self.fields, self.required_fields)
+
+ return value
+
+
+class CSVChoicesMixin:
+ STATIC_CHOICES = True
+
+ def __init__(self, *, choices=(), **kwargs):
+ super().__init__(choices=choices, **kwargs)
+ self.choices = unpack_grouped_choices(choices)
+
+
+class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
+ """
+ A CSV field which accepts a single selection value.
+ """
+ pass
+
+
+class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
+ """
+ A CSV field which accepts multiple selection values.
+ """
+ def to_python(self, value):
+ if not value:
+ return []
+ if not isinstance(value, str):
+ raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
+ return value.split(',')
+
+
+class CSVTypedChoiceField(forms.TypedChoiceField):
+ STATIC_CHOICES = True
+
+
+class CSVModelChoiceField(forms.ModelChoiceField):
+ """
+ Extends Django's `ModelChoiceField` to provide additional validation for CSV values.
+ """
+ default_error_messages = {
+ 'invalid_choice': 'Object not found.',
+ }
+
+ def to_python(self, value):
+ try:
+ return super().to_python(value)
+ except MultipleObjectsReturned:
+ raise forms.ValidationError(
+ f'"{value}" is not a unique value for this field; multiple objects were found'
+ )
+
+
+class CSVContentTypeField(CSVModelChoiceField):
+ """
+ CSV field for referencing a single content type, in the form `.`.
+ """
+ STATIC_CHOICES = True
+
+ def prepare_value(self, value):
+ return content_type_identifier(value)
+
+ def to_python(self, value):
+ if not value:
+ return None
+ try:
+ app_label, model = value.split('.')
+ except ValueError:
+ raise forms.ValidationError(f'Object type must be specified as "."')
+ try:
+ return self.queryset.get(app_label=app_label, model=model)
+ except ObjectDoesNotExist:
+ raise forms.ValidationError(f'Invalid object type')
+
+
+class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
+ """
+ CSV field for referencing one or more content types, in the form `.`.
+ """
+ STATIC_CHOICES = True
+
+ # TODO: Improve validation of selected ContentTypes
+ def prepare_value(self, value):
+ if type(value) is str:
+ ct_filter = Q()
+ for name in value.split(','):
+ app_label, model = name.split('.')
+ ct_filter |= Q(app_label=app_label, model=model)
+ return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
+ return content_type_identifier(value)
diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py
new file mode 100644
index 000000000..1bc8b9ec4
--- /dev/null
+++ b/netbox/utilities/forms/fields/dynamic.py
@@ -0,0 +1,141 @@
+import django_filters
+from django import forms
+from django.conf import settings
+from django.forms import BoundField
+from django.urls import reverse
+
+from utilities.forms import widgets
+
+__all__ = (
+ 'DynamicModelChoiceField',
+ 'DynamicModelMultipleChoiceField',
+)
+
+
+class DynamicModelChoiceMixin:
+ """
+ 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.
+
+ Attributes:
+ query_params: A dictionary of additional key/value pairs to attach to the API request
+ initial_params: A dictionary of child field references to use for selecting a parent field's initial value
+ null_option: The string used to represent a null selection (if any)
+ disabled_indicator: The name of the field which, if populated, will disable selection of the
+ choice (optional)
+ fetch_trigger: The event type which will cause the select element to
+ fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
+ """
+ filter = django_filters.ModelChoiceFilter
+ widget = widgets.APISelect
+
+ def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
+ fetch_trigger=None, empty_label=None, *args, **kwargs):
+ self.query_params = query_params or {}
+ self.initial_params = initial_params or {}
+ self.null_option = null_option
+ self.disabled_indicator = disabled_indicator
+ self.fetch_trigger = fetch_trigger
+
+ # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
+ # by widget_attrs()
+ self.to_field_name = kwargs.get('to_field_name')
+ self.empty_option = empty_label or ""
+
+ super().__init__(*args, **kwargs)
+
+ def widget_attrs(self, widget):
+ attrs = {
+ 'data-empty-option': self.empty_option
+ }
+
+ # Set value-field attribute if the field specifies to_field_name
+ if self.to_field_name:
+ attrs['value-field'] = self.to_field_name
+
+ # Set the string used to represent a null option
+ if self.null_option is not None:
+ attrs['data-null-option'] = self.null_option
+
+ # Set the disabled indicator, if any
+ if self.disabled_indicator is not None:
+ attrs['disabled-indicator'] = self.disabled_indicator
+
+ # Set the fetch trigger, if any.
+ if self.fetch_trigger is not None:
+ attrs['data-fetch-trigger'] = self.fetch_trigger
+
+ # Attach any static query parameters
+ if (len(self.query_params) > 0):
+ widget.add_query_params(self.query_params)
+
+ return attrs
+
+ def get_bound_field(self, form, field_name):
+ bound_field = BoundField(form, self, field_name)
+
+ # Set initial value based on prescribed child fields (if not already set)
+ if not self.initial and self.initial_params:
+ filter_kwargs = {}
+ for kwarg, child_field in self.initial_params.items():
+ value = form.initial.get(child_field.lstrip('$'))
+ if value:
+ filter_kwargs[kwarg] = value
+ if filter_kwargs:
+ self.initial = self.queryset.filter(**filter_kwargs).first()
+
+ # 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, ValueError):
+ # 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):
+ """
+ Dynamic selection field for a single object, backed by NetBox's REST API.
+ """
+ def clean(self, value):
+ """
+ When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+ string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
+ """
+ if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
+ return None
+ return super().clean(value)
+
+
+class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
+ """
+ A multiple-choice version of `DynamicModelChoiceField`.
+ """
+ filter = django_filters.ModelMultipleChoiceFilter
+ widget = widgets.APISelectMultiple
+
+ def clean(self, value):
+ """
+ When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+ string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
+ """
+ if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
+ value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
+ return [None, *value]
+ return super().clean(value)
diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py
new file mode 100644
index 000000000..214775f03
--- /dev/null
+++ b/netbox/utilities/forms/fields/expandable.py
@@ -0,0 +1,54 @@
+import re
+
+from django import forms
+
+from utilities.forms.constants import *
+from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
+
+__all__ = (
+ 'ExpandableIPAddressField',
+ 'ExpandableNameField',
+)
+
+
+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. Example: [ge,xe]-0/0/[0-9]
+ """
+
+ 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]
diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py
new file mode 100644
index 000000000..c2357a6e8
--- /dev/null
+++ b/netbox/utilities/forms/fields/fields.py
@@ -0,0 +1,127 @@
+import json
+
+from django import forms
+from django.db.models import Count
+from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
+from netaddr import AddrFormatError, EUI
+
+from utilities.forms import widgets
+from utilities.validators import EnhancedURLValidator
+
+__all__ = (
+ 'ColorField',
+ 'CommentField',
+ 'JSONField',
+ 'LaxURLField',
+ 'MACAddressField',
+ 'SlugField',
+ 'TagFilterField',
+)
+
+
+class CommentField(forms.CharField):
+ """
+ A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
+ """
+ widget = forms.Textarea
+ # TODO: Port Markdown cheat sheet to internal documentation
+ help_text = """
+
+
+ Markdown syntax is supported
+ """
+
+ def __init__(self, *, help_text=help_text, required=False, **kwargs):
+ super().__init__(help_text=help_text, required=required, **kwargs)
+
+
+class SlugField(forms.SlugField):
+ """
+ Extend Django's built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
+
+ Parameters:
+ slug_source: Name of the form field from which the slug value will be derived
+ """
+ widget = widgets.SlugWidget
+ help_text = "URL-friendly unique shorthand"
+
+ def __init__(self, *, slug_source='name', help_text=help_text, **kwargs):
+ super().__init__(help_text=help_text, **kwargs)
+
+ self.widget.attrs['slug-source'] = slug_source
+
+
+class ColorField(forms.CharField):
+ """
+ A field which represents a color value in hexadecimal `RRGGBB` format. Utilizes NetBox's `ColorSelect` widget to
+ render choices.
+ """
+ widget = widgets.ColorSelect
+
+
+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.StaticSelectMultiple
+
+ 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 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)
+
+
+class MACAddressField(forms.Field):
+ """
+ Validates a 48-bit MAC address.
+ """
+ widget = forms.CharField
+ default_error_messages = {
+ 'invalid': 'MAC address must be in EUI-48 format',
+ }
+
+ def to_python(self, value):
+ value = super().to_python(value)
+
+ # Validate MAC address format
+ try:
+ value = EUI(value.strip())
+ except AddrFormatError:
+ raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
+
+ return value