Procházet zdrojové kódy

Refactor & document supported form fields

jeremystretch před 4 roky
rodič
revize
cf3ca5a661

+ 68 - 0
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

+ 0 - 526
netbox/utilities/forms/fields.py

@@ -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 = '<i class="mdi mdi-information-outline"></i> '\
-                       '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1">'\
-                       '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().__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 <a href="https://json.org/">JSON</a> 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 <app>.<model>
-    """
-    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 "<app>.<model>"')
-        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: <code>[ge,xe]-0/0/[0-9]</code>
-                """
-
-    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.<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]
-
-
-#
-# 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)

+ 5 - 0
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 *

+ 37 - 0
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

+ 193 - 0
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 `<app>.<model>`.
+    """
+    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 "<app>.<model>"')
+        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 `<app>.<model>`.
+    """
+    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)

+ 141 - 0
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)

+ 54 - 0
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: <code>[ge,xe]-0/0/[0-9]</code>
+                """
+
+    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.<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]

+ 127 - 0
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 = """
+        <i class="mdi mdi-information-outline"></i>
+        <a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1">
+        Markdown</a> 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 <a href="https://json.org/">JSON</a> 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