From cf3ca5a661cc015baf4ef462be07e91c09db0ede Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 31 Jan 2022 14:10:13 -0500 Subject: [PATCH] Refactor & document supported form fields --- docs/plugins/development/forms.md | 68 +++ netbox/utilities/forms/fields.py | 526 ------------------ netbox/utilities/forms/fields/__init__.py | 5 + .../utilities/forms/fields/content_types.py | 37 ++ netbox/utilities/forms/fields/csv.py | 193 +++++++ netbox/utilities/forms/fields/dynamic.py | 141 +++++ netbox/utilities/forms/fields/expandable.py | 54 ++ netbox/utilities/forms/fields/fields.py | 127 +++++ 8 files changed, 625 insertions(+), 526 deletions(-) delete mode 100644 netbox/utilities/forms/fields.py create mode 100644 netbox/utilities/forms/fields/__init__.py create mode 100644 netbox/utilities/forms/fields/content_types.py create mode 100644 netbox/utilities/forms/fields/csv.py create mode 100644 netbox/utilities/forms/fields/dynamic.py create mode 100644 netbox/utilities/forms/fields/expandable.py create mode 100644 netbox/utilities/forms/fields/fields.py 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