diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
deleted file mode 100644
index fe98d9ca3..000000000
--- a/netbox/extras/forms.py
+++ /dev/null
@@ -1,988 +0,0 @@
-from django import forms
-from django.contrib.auth.models import User
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.forms import SimpleArrayField
-from django.utils.safestring import mark_safe
-from django.utils.translation import gettext as _
-
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
-from tenancy.models import Tenant, TenantGroup
-from utilities.forms import (
- add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
- CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm,
- CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
- StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
-)
-from virtualization.models import Cluster, ClusterGroup
-from .choices import *
-from .models import *
-from .utils import FeatureQuery
-
-
-#
-# Custom fields
-#
-
-class CustomFieldForm(BootstrapMixin, forms.ModelForm):
- content_types = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields')
- )
-
- class Meta:
- model = CustomField
- fields = '__all__'
- fieldsets = (
- ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
- ('Assigned Models', ('content_types',)),
- ('Behavior', ('filter_logic',)),
- ('Values', ('default', 'choices')),
- ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
- )
-
-
-class CustomFieldCSVForm(CSVModelForm):
- content_types = CSVMultipleContentTypeField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- help_text="One or more assigned object types"
- )
- choices = SimpleArrayField(
- base_field=forms.CharField(),
- required=False,
- help_text='Comma-separated list of field choices'
- )
-
- class Meta:
- model = CustomField
- fields = (
- 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
- 'choices', 'weight',
- )
-
-
-class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=CustomField.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- description = forms.CharField(
- required=False
- )
- required = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- weight = forms.IntegerField(
- required=False
- )
-
- class Meta:
- nullable_fields = []
-
-
-class CustomFieldFilterForm(BootstrapMixin, forms.Form):
- field_groups = [
- ['q'],
- ['type', 'content_types'],
- ['weight', 'required'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- content_types = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- required=False
- )
- type = forms.MultipleChoiceField(
- choices=CustomFieldTypeChoices,
- required=False,
- widget=StaticSelectMultiple(),
- label=_('Field type')
- )
- weight = forms.IntegerField(
- required=False
- )
- required = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
-
-
-#
-# Custom links
-#
-
-class CustomLinkForm(BootstrapMixin, forms.ModelForm):
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_links')
- )
-
- class Meta:
- model = CustomLink
- fields = '__all__'
- fieldsets = (
- ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
- ('Templates', ('link_text', 'link_url')),
- )
- widgets = {
- 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
- 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
- }
- help_texts = {
- 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}
. '
- 'Links which render as empty text will not be displayed.',
- 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}
.',
- }
-
-
-class CustomLinkCSVForm(CSVModelForm):
- content_type = CSVContentTypeField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_links'),
- help_text="Assigned object type"
- )
-
- class Meta:
- model = CustomLink
- fields = (
- 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
- )
-
-
-class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=CustomLink.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- required=False
- )
- new_window = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- weight = forms.IntegerField(
- required=False
- )
- button_class = forms.ChoiceField(
- choices=CustomLinkButtonClassChoices,
- required=False,
- widget=StaticSelect()
- )
-
- class Meta:
- nullable_fields = []
-
-
-class CustomLinkFilterForm(BootstrapMixin, forms.Form):
- field_groups = [
- ['q'],
- ['content_type', 'weight', 'new_window'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- required=False
- )
- weight = forms.IntegerField(
- required=False
- )
- new_window = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
-
-
-#
-# Export templates
-#
-
-class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_links')
- )
-
- class Meta:
- model = ExportTemplate
- fields = '__all__'
- fieldsets = (
- ('Custom Link', ('name', 'content_type', 'description')),
- ('Template', ('template_code',)),
- ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
- )
- widgets = {
- 'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
- }
-
-
-class ExportTemplateCSVForm(CSVModelForm):
- content_type = CSVContentTypeField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('export_templates'),
- help_text="Assigned object type"
- )
-
- class Meta:
- model = ExportTemplate
- fields = (
- 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
- )
-
-
-class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=ExportTemplate.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- required=False
- )
- description = forms.CharField(
- max_length=200,
- required=False
- )
- mime_type = forms.CharField(
- max_length=50,
- required=False
- )
- file_extension = forms.CharField(
- max_length=15,
- required=False
- )
- as_attachment = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
-
- class Meta:
- nullable_fields = ['description', 'mime_type', 'file_extension']
-
-
-class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
- field_groups = [
- ['q'],
- ['content_type', 'mime_type', 'file_extension', 'as_attachment'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- required=False
- )
- mime_type = forms.CharField(
- required=False,
- label=_('MIME type')
- )
- file_extension = forms.CharField(
- required=False
- )
- as_attachment = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
-
-
-#
-# Webhooks
-#
-
-class WebhookForm(BootstrapMixin, forms.ModelForm):
- content_types = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('webhooks')
- )
-
- class Meta:
- model = Webhook
- fields = '__all__'
- fieldsets = (
- ('Webhook', ('name', 'enabled')),
- ('Assigned Models', ('content_types',)),
- ('Events', ('type_create', 'type_update', 'type_delete')),
- ('HTTP Request', (
- 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
- )),
- ('SSL', ('ssl_verification', 'ca_file_path')),
- )
- widgets = {
- 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
- 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
- }
-
-
-class WebhookCSVForm(CSVModelForm):
- content_types = CSVMultipleContentTypeField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('webhooks'),
- help_text="One or more assigned object types"
- )
-
- class Meta:
- model = Webhook
- fields = (
- 'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
- 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
- 'ca_file_path'
- )
-
-
-class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=Webhook.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- enabled = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- type_create = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- type_update = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- type_delete = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- http_method = forms.ChoiceField(
- choices=WebhookHttpMethodChoices,
- required=False
- )
- payload_url = forms.CharField(
- required=False
- )
- ssl_verification = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- secret = forms.CharField(
- required=False
- )
- ca_file_path = forms.CharField(
- required=False
- )
-
- class Meta:
- nullable_fields = ['secret', 'ca_file_path']
-
-
-class WebhookFilterForm(BootstrapMixin, forms.Form):
- field_groups = [
- ['q'],
- ['content_types', 'http_method', 'enabled'],
- ['type_create', 'type_update', 'type_delete'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- content_types = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_fields'),
- required=False
- )
- http_method = forms.MultipleChoiceField(
- choices=WebhookHttpMethodChoices,
- required=False,
- widget=StaticSelectMultiple(),
- label=_('HTTP method')
- )
- enabled = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
- type_create = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
- type_update = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
- type_delete = forms.NullBooleanField(
- required=False,
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
-
-
-#
-# Custom field models
-#
-
-class CustomFieldsMixin:
- """
- Extend a Form to include custom field support.
- """
- def __init__(self, *args, **kwargs):
- self.custom_fields = []
-
- super().__init__(*args, **kwargs)
-
- self._append_customfield_fields()
-
- def _get_content_type(self):
- """
- Return the ContentType of the form's model.
- """
- if not hasattr(self, 'model'):
- raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
- return ContentType.objects.get_for_model(self.model)
-
- def _get_form_field(self, customfield):
- return customfield.to_form_field()
-
- def _append_customfield_fields(self):
- """
- Append form fields for all CustomFields assigned to this object type.
- """
- content_type = self._get_content_type()
-
- # Append form fields; assign initial values if modifying and existing object
- for customfield in CustomField.objects.filter(content_types=content_type):
- field_name = f'cf_{customfield.name}'
- self.fields[field_name] = self._get_form_field(customfield)
-
- # Annotate the field in the list of CustomField form fields
- self.custom_fields.append(field_name)
-
-
-class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
- """
- Extend ModelForm to include custom field support.
- """
- def _get_content_type(self):
- return ContentType.objects.get_for_model(self._meta.model)
-
- def _get_form_field(self, customfield):
- if self.instance.pk:
- form_field = customfield.to_form_field(set_initial=False)
- form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
- return form_field
-
- return customfield.to_form_field()
-
- def clean(self):
-
- # Save custom field data on instance
- for cf_name in self.custom_fields:
- key = cf_name[3:] # Strip "cf_" from field name
- value = self.cleaned_data.get(cf_name)
- empty_values = self.fields[cf_name].empty_values
- # Convert "empty" values to null
- self.instance.custom_field_data[key] = value if value not in empty_values else None
-
- return super().clean()
-
-
-class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
-
- def _get_form_field(self, customfield):
- return customfield.to_form_field(for_csv_import=True)
-
-
-class CustomFieldModelBulkEditForm(BulkEditForm):
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.custom_fields = []
- self.obj_type = ContentType.objects.get_for_model(self.model)
-
- # Add all applicable CustomFields to the form
- custom_fields = CustomField.objects.filter(content_types=self.obj_type)
- for cf in custom_fields:
- # Annotate non-required custom fields as nullable
- if not cf.required:
- self.nullable_fields.append(cf.name)
- self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
- # Annotate this as a custom field
- self.custom_fields.append(cf.name)
-
-
-class CustomFieldModelFilterForm(forms.Form):
-
- def __init__(self, *args, **kwargs):
-
- self.obj_type = ContentType.objects.get_for_model(self.model)
-
- super().__init__(*args, **kwargs)
-
- # Add all applicable CustomFields to the form
- self.custom_field_filters = []
- custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
- filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
- )
- for cf in custom_fields:
- field_name = 'cf_{}'.format(cf.name)
- self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
- self.custom_field_filters.append(field_name)
-
-
-#
-# Tags
-#
-
-class TagForm(BootstrapMixin, forms.ModelForm):
- slug = SlugField()
-
- class Meta:
- model = Tag
- fields = [
- 'name', 'slug', 'color', 'description'
- ]
- fieldsets = (
- ('Tag', ('name', 'slug', 'color', 'description')),
- )
-
-
-class TagCSVForm(CSVModelForm):
- slug = SlugField()
-
- class Meta:
- model = Tag
- fields = ('name', 'slug', 'color', 'description')
- help_texts = {
- 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
- }
-
-
-class AddRemoveTagsForm(forms.Form):
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Add add/remove tags fields
- self.fields['add_tags'] = DynamicModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- required=False
- )
- self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- required=False
- )
-
-
-class TagFilterForm(BootstrapMixin, forms.Form):
- model = Tag
- q = forms.CharField(
- required=False,
- label=_('Search')
- )
- content_type_id = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
- required=False,
- label=_('Tagged object type')
- )
-
-
-class TagBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- color = ColorField(
- required=False
- )
- description = forms.CharField(
- max_length=200,
- required=False
- )
-
- class Meta:
- nullable_fields = ['description']
-
-
-#
-# Config contexts
-#
-
-class ConfigContextForm(BootstrapMixin, forms.ModelForm):
- regions = DynamicModelMultipleChoiceField(
- queryset=Region.objects.all(),
- required=False
- )
- site_groups = DynamicModelMultipleChoiceField(
- queryset=SiteGroup.objects.all(),
- required=False
- )
- sites = DynamicModelMultipleChoiceField(
- queryset=Site.objects.all(),
- required=False
- )
- device_types = DynamicModelMultipleChoiceField(
- queryset=DeviceType.objects.all(),
- required=False
- )
- roles = DynamicModelMultipleChoiceField(
- queryset=DeviceRole.objects.all(),
- required=False
- )
- platforms = DynamicModelMultipleChoiceField(
- queryset=Platform.objects.all(),
- required=False
- )
- cluster_groups = DynamicModelMultipleChoiceField(
- queryset=ClusterGroup.objects.all(),
- required=False
- )
- clusters = DynamicModelMultipleChoiceField(
- queryset=Cluster.objects.all(),
- required=False
- )
- tenant_groups = DynamicModelMultipleChoiceField(
- queryset=TenantGroup.objects.all(),
- required=False
- )
- tenants = DynamicModelMultipleChoiceField(
- queryset=Tenant.objects.all(),
- required=False
- )
- tags = DynamicModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- required=False
- )
- data = JSONField(
- label=''
- )
-
- class Meta:
- model = ConfigContext
- fields = (
- 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
- 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
- )
-
-
-class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=ConfigContext.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- weight = forms.IntegerField(
- required=False,
- min_value=0
- )
- is_active = forms.NullBooleanField(
- required=False,
- widget=BulkEditNullBooleanSelect()
- )
- description = forms.CharField(
- required=False,
- max_length=100
- )
-
- class Meta:
- nullable_fields = [
- 'description',
- ]
-
-
-class ConfigContextFilterForm(BootstrapMixin, forms.Form):
- field_groups = [
- ['q', 'tag'],
- ['region_id', 'site_group_id', 'site_id'],
- ['device_type_id', 'platform_id', 'role_id'],
- ['cluster_group_id', 'cluster_id'],
- ['tenant_group_id', 'tenant_id']
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- region_id = DynamicModelMultipleChoiceField(
- queryset=Region.objects.all(),
- required=False,
- label=_('Regions'),
- fetch_trigger='open'
- )
- site_group_id = DynamicModelMultipleChoiceField(
- queryset=SiteGroup.objects.all(),
- required=False,
- label=_('Site groups'),
- fetch_trigger='open'
- )
- site_id = DynamicModelMultipleChoiceField(
- queryset=Site.objects.all(),
- required=False,
- label=_('Sites'),
- fetch_trigger='open'
- )
- device_type_id = DynamicModelMultipleChoiceField(
- queryset=DeviceType.objects.all(),
- required=False,
- label=_('Device types'),
- fetch_trigger='open'
- )
- role_id = DynamicModelMultipleChoiceField(
- queryset=DeviceRole.objects.all(),
- required=False,
- label=_('Roles'),
- fetch_trigger='open'
- )
- platform_id = DynamicModelMultipleChoiceField(
- queryset=Platform.objects.all(),
- required=False,
- label=_('Platforms'),
- fetch_trigger='open'
- )
- cluster_group_id = DynamicModelMultipleChoiceField(
- queryset=ClusterGroup.objects.all(),
- required=False,
- label=_('Cluster groups'),
- fetch_trigger='open'
- )
- cluster_id = DynamicModelMultipleChoiceField(
- queryset=Cluster.objects.all(),
- required=False,
- label=_('Clusters'),
- fetch_trigger='open'
- )
- tenant_group_id = DynamicModelMultipleChoiceField(
- queryset=TenantGroup.objects.all(),
- required=False,
- label=_('Tenant groups'),
- fetch_trigger='open'
- )
- tenant_id = DynamicModelMultipleChoiceField(
- queryset=Tenant.objects.all(),
- required=False,
- label=_('Tenant'),
- fetch_trigger='open'
- )
- tag = DynamicModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- to_field_name='slug',
- required=False,
- label=_('Tags'),
- fetch_trigger='open'
- )
-
-
-#
-# Filter form for local config context data
-#
-
-class LocalConfigContextFilterForm(forms.Form):
- local_context_data = forms.NullBooleanField(
- required=False,
- label=_('Has local config context data'),
- widget=StaticSelect(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
-
-
-#
-# Image attachments
-#
-
-class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
-
- class Meta:
- model = ImageAttachment
- fields = [
- 'name', 'image',
- ]
-
-
-#
-# Journal entries
-#
-
-class JournalEntryForm(BootstrapMixin, forms.ModelForm):
- comments = CommentField()
-
- kind = forms.ChoiceField(
- choices=add_blank_choice(JournalEntryKindChoices),
- required=False,
- widget=StaticSelect()
- )
-
- class Meta:
- model = JournalEntry
- fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
- widgets = {
- 'assigned_object_type': forms.HiddenInput,
- 'assigned_object_id': forms.HiddenInput,
- }
-
-
-class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=JournalEntry.objects.all(),
- widget=forms.MultipleHiddenInput
- )
- kind = forms.ChoiceField(
- choices=JournalEntryKindChoices,
- required=False
- )
- comments = forms.CharField(
- required=False,
- widget=forms.Textarea()
- )
-
- class Meta:
- nullable_fields = []
-
-
-class JournalEntryFilterForm(BootstrapMixin, forms.Form):
- model = JournalEntry
- field_groups = [
- ['q'],
- ['created_before', 'created_after', 'created_by_id'],
- ['assigned_object_type_id', 'kind']
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- created_after = forms.DateTimeField(
- required=False,
- label=_('After'),
- widget=DateTimePicker()
- )
- created_before = forms.DateTimeField(
- required=False,
- label=_('Before'),
- widget=DateTimePicker()
- )
- created_by_id = DynamicModelMultipleChoiceField(
- queryset=User.objects.all(),
- required=False,
- label=_('User'),
- widget=APISelectMultiple(
- api_url='/api/users/users/',
- ),
- fetch_trigger='open'
- )
- assigned_object_type_id = DynamicModelMultipleChoiceField(
- queryset=ContentType.objects.all(),
- required=False,
- label=_('Object Type'),
- widget=APISelectMultiple(
- api_url='/api/extras/content-types/',
- ),
- fetch_trigger='open'
- )
- kind = forms.ChoiceField(
- choices=add_blank_choice(JournalEntryKindChoices),
- required=False,
- widget=StaticSelect()
- )
-
-
-#
-# Change logging
-#
-
-class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
- model = ObjectChange
- field_groups = [
- ['q'],
- ['time_before', 'time_after', 'action'],
- ['user_id', 'changed_object_type_id'],
- ]
- q = forms.CharField(
- required=False,
- widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
- label=_('Search')
- )
- time_after = forms.DateTimeField(
- required=False,
- label=_('After'),
- widget=DateTimePicker()
- )
- time_before = forms.DateTimeField(
- required=False,
- label=_('Before'),
- widget=DateTimePicker()
- )
- action = forms.ChoiceField(
- choices=add_blank_choice(ObjectChangeActionChoices),
- required=False,
- widget=StaticSelect()
- )
- user_id = DynamicModelMultipleChoiceField(
- queryset=User.objects.all(),
- required=False,
- label=_('User'),
- widget=APISelectMultiple(
- api_url='/api/users/users/',
- ),
- fetch_trigger='open'
- )
- changed_object_type_id = DynamicModelMultipleChoiceField(
- queryset=ContentType.objects.all(),
- required=False,
- label=_('Object Type'),
- widget=APISelectMultiple(
- api_url='/api/extras/content-types/',
- ),
- fetch_trigger='open'
- )
-
-
-#
-# Scripts
-#
-
-class ScriptForm(BootstrapMixin, forms.Form):
- _commit = forms.BooleanField(
- required=False,
- initial=True,
- label="Commit changes",
- help_text="Commit changes to the database (uncheck for a dry-run)"
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Move _commit to the end of the form
- commit = self.fields.pop('_commit')
- self.fields['_commit'] = commit
-
- @property
- def requires_input(self):
- """
- A boolean indicating whether the form requires user input (ignore the _commit field).
- """
- return bool(len(self.fields) > 1)
diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py
new file mode 100644
index 000000000..1584e2f51
--- /dev/null
+++ b/netbox/extras/forms/__init__.py
@@ -0,0 +1,6 @@
+from .models import *
+from .filtersets import *
+from .bulk_edit import *
+from .bulk_import import *
+from .customfields import *
+from .scripts import *
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
new file mode 100644
index 000000000..b85a74a5b
--- /dev/null
+++ b/netbox/extras/forms/bulk_edit.py
@@ -0,0 +1,199 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from extras.choices import *
+from extras.models import *
+from extras.utils import FeatureQuery
+from utilities.forms import (
+ BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
+)
+
+__all__ = (
+ 'ConfigContextBulkEditForm',
+ 'CustomFieldBulkEditForm',
+ 'CustomLinkBulkEditForm',
+ 'ExportTemplateBulkEditForm',
+ 'JournalEntryBulkEditForm',
+ 'TagBulkEditForm',
+ 'WebhookBulkEditForm',
+)
+
+
+class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=CustomField.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ description = forms.CharField(
+ required=False
+ )
+ required = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ weight = forms.IntegerField(
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = []
+
+
+class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=CustomLink.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False
+ )
+ new_window = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ weight = forms.IntegerField(
+ required=False
+ )
+ button_class = forms.ChoiceField(
+ choices=CustomLinkButtonClassChoices,
+ required=False,
+ widget=StaticSelect()
+ )
+
+ class Meta:
+ nullable_fields = []
+
+
+class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ExportTemplate.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ mime_type = forms.CharField(
+ max_length=50,
+ required=False
+ )
+ file_extension = forms.CharField(
+ max_length=15,
+ required=False
+ )
+ as_attachment = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+
+ class Meta:
+ nullable_fields = ['description', 'mime_type', 'file_extension']
+
+
+class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=Webhook.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ enabled = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ type_create = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ type_update = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ type_delete = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ http_method = forms.ChoiceField(
+ choices=WebhookHttpMethodChoices,
+ required=False
+ )
+ payload_url = forms.CharField(
+ required=False
+ )
+ ssl_verification = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ secret = forms.CharField(
+ required=False
+ )
+ ca_file_path = forms.CharField(
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['secret', 'ca_file_path']
+
+
+class TagBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ color = ColorField(
+ required=False
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ['description']
+
+
+class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ConfigContext.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ weight = forms.IntegerField(
+ required=False,
+ min_value=0
+ )
+ is_active = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ description = forms.CharField(
+ required=False,
+ max_length=100
+ )
+
+ class Meta:
+ nullable_fields = [
+ 'description',
+ ]
+
+
+class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=JournalEntry.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ kind = forms.ChoiceField(
+ choices=JournalEntryKindChoices,
+ required=False
+ )
+ comments = forms.CharField(
+ required=False,
+ widget=forms.Textarea()
+ )
+
+ class Meta:
+ nullable_fields = []
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
new file mode 100644
index 000000000..fb8cf53e8
--- /dev/null
+++ b/netbox/extras/forms/bulk_import.py
@@ -0,0 +1,91 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.forms import SimpleArrayField
+from django.utils.safestring import mark_safe
+
+from extras.models import *
+from extras.utils import FeatureQuery
+from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
+
+__all__ = (
+ 'CustomFieldCSVForm',
+ 'CustomLinkCSVForm',
+ 'ExportTemplateCSVForm',
+ 'TagCSVForm',
+ 'WebhookCSVForm',
+)
+
+
+class CustomFieldCSVForm(CSVModelForm):
+ content_types = CSVMultipleContentTypeField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ help_text="One or more assigned object types"
+ )
+ choices = SimpleArrayField(
+ base_field=forms.CharField(),
+ required=False,
+ help_text='Comma-separated list of field choices'
+ )
+
+ class Meta:
+ model = CustomField
+ fields = (
+ 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
+ 'choices', 'weight',
+ )
+
+
+class CustomLinkCSVForm(CSVModelForm):
+ content_type = CSVContentTypeField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_links'),
+ help_text="Assigned object type"
+ )
+
+ class Meta:
+ model = CustomLink
+ fields = (
+ 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
+ )
+
+
+class ExportTemplateCSVForm(CSVModelForm):
+ content_type = CSVContentTypeField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('export_templates'),
+ help_text="Assigned object type"
+ )
+
+ class Meta:
+ model = ExportTemplate
+ fields = (
+ 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
+ )
+
+
+class WebhookCSVForm(CSVModelForm):
+ content_types = CSVMultipleContentTypeField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('webhooks'),
+ help_text="One or more assigned object types"
+ )
+
+ class Meta:
+ model = Webhook
+ fields = (
+ 'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
+ 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
+ 'ca_file_path'
+ )
+
+
+class TagCSVForm(CSVModelForm):
+ slug = SlugField()
+
+ class Meta:
+ model = Tag
+ fields = ('name', 'slug', 'color', 'description')
+ help_texts = {
+ 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
+ }
diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py
new file mode 100644
index 000000000..9f68467fa
--- /dev/null
+++ b/netbox/extras/forms/customfields.py
@@ -0,0 +1,123 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from extras.choices import *
+from extras.models import *
+from utilities.forms import BulkEditForm, CSVModelForm
+
+__all__ = (
+ 'CustomFieldModelCSVForm',
+ 'CustomFieldModelBulkEditForm',
+ 'CustomFieldModelFilterForm',
+ 'CustomFieldModelForm',
+ 'CustomFieldsMixin',
+)
+
+
+class CustomFieldsMixin:
+ """
+ Extend a Form to include custom field support.
+ """
+ def __init__(self, *args, **kwargs):
+ self.custom_fields = []
+
+ super().__init__(*args, **kwargs)
+
+ self._append_customfield_fields()
+
+ def _get_content_type(self):
+ """
+ Return the ContentType of the form's model.
+ """
+ if not hasattr(self, 'model'):
+ raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
+ return ContentType.objects.get_for_model(self.model)
+
+ def _get_form_field(self, customfield):
+ return customfield.to_form_field()
+
+ def _append_customfield_fields(self):
+ """
+ Append form fields for all CustomFields assigned to this object type.
+ """
+ content_type = self._get_content_type()
+
+ # Append form fields; assign initial values if modifying and existing object
+ for customfield in CustomField.objects.filter(content_types=content_type):
+ field_name = f'cf_{customfield.name}'
+ self.fields[field_name] = self._get_form_field(customfield)
+
+ # Annotate the field in the list of CustomField form fields
+ self.custom_fields.append(field_name)
+
+
+class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
+ """
+ Extend ModelForm to include custom field support.
+ """
+ def _get_content_type(self):
+ return ContentType.objects.get_for_model(self._meta.model)
+
+ def _get_form_field(self, customfield):
+ if self.instance.pk:
+ form_field = customfield.to_form_field(set_initial=False)
+ form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
+ return form_field
+
+ return customfield.to_form_field()
+
+ def clean(self):
+
+ # Save custom field data on instance
+ for cf_name in self.custom_fields:
+ key = cf_name[3:] # Strip "cf_" from field name
+ value = self.cleaned_data.get(cf_name)
+ empty_values = self.fields[cf_name].empty_values
+ # Convert "empty" values to null
+ self.instance.custom_field_data[key] = value if value not in empty_values else None
+
+ return super().clean()
+
+
+class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
+
+ def _get_form_field(self, customfield):
+ return customfield.to_form_field(for_csv_import=True)
+
+
+class CustomFieldModelBulkEditForm(BulkEditForm):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.custom_fields = []
+ self.obj_type = ContentType.objects.get_for_model(self.model)
+
+ # Add all applicable CustomFields to the form
+ custom_fields = CustomField.objects.filter(content_types=self.obj_type)
+ for cf in custom_fields:
+ # Annotate non-required custom fields as nullable
+ if not cf.required:
+ self.nullable_fields.append(cf.name)
+ self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
+ # Annotate this as a custom field
+ self.custom_fields.append(cf.name)
+
+
+class CustomFieldModelFilterForm(forms.Form):
+
+ def __init__(self, *args, **kwargs):
+
+ self.obj_type = ContentType.objects.get_for_model(self.model)
+
+ super().__init__(*args, **kwargs)
+
+ # Add all applicable CustomFields to the form
+ self.custom_field_filters = []
+ custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
+ filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
+ )
+ for cf in custom_fields:
+ field_name = 'cf_{}'.format(cf.name)
+ self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
+ self.custom_field_filters.append(field_name)
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
new file mode 100644
index 000000000..6196ba8da
--- /dev/null
+++ b/netbox/extras/forms/filtersets.py
@@ -0,0 +1,364 @@
+from django import forms
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
+
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from extras.choices import *
+from extras.models import *
+from extras.utils import FeatureQuery
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import (
+ add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField,
+ ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect,
+ StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
+)
+from virtualization.models import Cluster, ClusterGroup
+
+__all__ = (
+ 'ConfigContextFilterForm',
+ 'CustomFieldFilterForm',
+ 'CustomLinkFilterForm',
+ 'ExportTemplateFilterForm',
+ 'JournalEntryFilterForm',
+ 'LocalConfigContextFilterForm',
+ 'ObjectChangeFilterForm',
+ 'TagFilterForm',
+ 'WebhookFilterForm',
+)
+
+
+class CustomFieldFilterForm(BootstrapMixin, forms.Form):
+ field_groups = [
+ ['q'],
+ ['type', 'content_types'],
+ ['weight', 'required'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ content_types = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False
+ )
+ type = forms.MultipleChoiceField(
+ choices=CustomFieldTypeChoices,
+ required=False,
+ widget=StaticSelectMultiple(),
+ label=_('Field type')
+ )
+ weight = forms.IntegerField(
+ required=False
+ )
+ required = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class CustomLinkFilterForm(BootstrapMixin, forms.Form):
+ field_groups = [
+ ['q'],
+ ['content_type', 'weight', 'new_window'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False
+ )
+ weight = forms.IntegerField(
+ required=False
+ )
+ new_window = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
+ field_groups = [
+ ['q'],
+ ['content_type', 'mime_type', 'file_extension', 'as_attachment'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False
+ )
+ mime_type = forms.CharField(
+ required=False,
+ label=_('MIME type')
+ )
+ file_extension = forms.CharField(
+ required=False
+ )
+ as_attachment = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class WebhookFilterForm(BootstrapMixin, forms.Form):
+ field_groups = [
+ ['q'],
+ ['content_types', 'http_method', 'enabled'],
+ ['type_create', 'type_update', 'type_delete'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ content_types = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields'),
+ required=False
+ )
+ http_method = forms.MultipleChoiceField(
+ choices=WebhookHttpMethodChoices,
+ required=False,
+ widget=StaticSelectMultiple(),
+ label=_('HTTP method')
+ )
+ enabled = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ type_create = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ type_update = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ type_delete = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class TagFilterForm(BootstrapMixin, forms.Form):
+ model = Tag
+ q = forms.CharField(
+ required=False,
+ label=_('Search')
+ )
+ content_type_id = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+ required=False,
+ label=_('Tagged object type')
+ )
+
+
+class ConfigContextFilterForm(BootstrapMixin, forms.Form):
+ field_groups = [
+ ['q', 'tag'],
+ ['region_id', 'site_group_id', 'site_id'],
+ ['device_type_id', 'platform_id', 'role_id'],
+ ['cluster_group_id', 'cluster_id'],
+ ['tenant_group_id', 'tenant_id']
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Regions'),
+ fetch_trigger='open'
+ )
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site groups'),
+ fetch_trigger='open'
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ label=_('Sites'),
+ fetch_trigger='open'
+ )
+ device_type_id = DynamicModelMultipleChoiceField(
+ queryset=DeviceType.objects.all(),
+ required=False,
+ label=_('Device types'),
+ fetch_trigger='open'
+ )
+ role_id = DynamicModelMultipleChoiceField(
+ queryset=DeviceRole.objects.all(),
+ required=False,
+ label=_('Roles'),
+ fetch_trigger='open'
+ )
+ platform_id = DynamicModelMultipleChoiceField(
+ queryset=Platform.objects.all(),
+ required=False,
+ label=_('Platforms'),
+ fetch_trigger='open'
+ )
+ cluster_group_id = DynamicModelMultipleChoiceField(
+ queryset=ClusterGroup.objects.all(),
+ required=False,
+ label=_('Cluster groups'),
+ fetch_trigger='open'
+ )
+ cluster_id = DynamicModelMultipleChoiceField(
+ queryset=Cluster.objects.all(),
+ required=False,
+ label=_('Clusters'),
+ fetch_trigger='open'
+ )
+ tenant_group_id = DynamicModelMultipleChoiceField(
+ queryset=TenantGroup.objects.all(),
+ required=False,
+ label=_('Tenant groups'),
+ fetch_trigger='open'
+ )
+ tenant_id = DynamicModelMultipleChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False,
+ label=_('Tenant'),
+ fetch_trigger='open'
+ )
+ tag = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ to_field_name='slug',
+ required=False,
+ label=_('Tags'),
+ fetch_trigger='open'
+ )
+
+
+class LocalConfigContextFilterForm(forms.Form):
+ local_context_data = forms.NullBooleanField(
+ required=False,
+ label=_('Has local config context data'),
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class JournalEntryFilterForm(BootstrapMixin, forms.Form):
+ model = JournalEntry
+ field_groups = [
+ ['q'],
+ ['created_before', 'created_after', 'created_by_id'],
+ ['assigned_object_type_id', 'kind']
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ created_after = forms.DateTimeField(
+ required=False,
+ label=_('After'),
+ widget=DateTimePicker()
+ )
+ created_before = forms.DateTimeField(
+ required=False,
+ label=_('Before'),
+ widget=DateTimePicker()
+ )
+ created_by_id = DynamicModelMultipleChoiceField(
+ queryset=User.objects.all(),
+ required=False,
+ label=_('User'),
+ widget=APISelectMultiple(
+ api_url='/api/users/users/',
+ ),
+ fetch_trigger='open'
+ )
+ assigned_object_type_id = DynamicModelMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ required=False,
+ label=_('Object Type'),
+ widget=APISelectMultiple(
+ api_url='/api/extras/content-types/',
+ ),
+ fetch_trigger='open'
+ )
+ kind = forms.ChoiceField(
+ choices=add_blank_choice(JournalEntryKindChoices),
+ required=False,
+ widget=StaticSelect()
+ )
+
+
+class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
+ model = ObjectChange
+ field_groups = [
+ ['q'],
+ ['time_before', 'time_after', 'action'],
+ ['user_id', 'changed_object_type_id'],
+ ]
+ q = forms.CharField(
+ required=False,
+ widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+ label=_('Search')
+ )
+ time_after = forms.DateTimeField(
+ required=False,
+ label=_('After'),
+ widget=DateTimePicker()
+ )
+ time_before = forms.DateTimeField(
+ required=False,
+ label=_('Before'),
+ widget=DateTimePicker()
+ )
+ action = forms.ChoiceField(
+ choices=add_blank_choice(ObjectChangeActionChoices),
+ required=False,
+ widget=StaticSelect()
+ )
+ user_id = DynamicModelMultipleChoiceField(
+ queryset=User.objects.all(),
+ required=False,
+ label=_('User'),
+ widget=APISelectMultiple(
+ api_url='/api/users/users/',
+ ),
+ fetch_trigger='open'
+ )
+ changed_object_type_id = DynamicModelMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ required=False,
+ label=_('Object Type'),
+ widget=APISelectMultiple(
+ api_url='/api/extras/content-types/',
+ ),
+ fetch_trigger='open'
+ )
diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py
new file mode 100644
index 000000000..7e462e62b
--- /dev/null
+++ b/netbox/extras/forms/models.py
@@ -0,0 +1,223 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from extras.choices import *
+from extras.models import *
+from extras.utils import FeatureQuery
+from tenancy.models import Tenant, TenantGroup
+from utilities.forms import (
+ add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
+ ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
+)
+from virtualization.models import Cluster, ClusterGroup
+
+__all__ = (
+ 'AddRemoveTagsForm',
+ 'ConfigContextForm',
+ 'CustomFieldForm',
+ 'CustomLinkForm',
+ 'ExportTemplateForm',
+ 'ImageAttachmentForm',
+ 'JournalEntryForm',
+ 'TagForm',
+ 'WebhookForm',
+)
+
+
+class CustomFieldForm(BootstrapMixin, forms.ModelForm):
+ content_types = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields')
+ )
+
+ class Meta:
+ model = CustomField
+ fields = '__all__'
+ fieldsets = (
+ ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
+ ('Assigned Models', ('content_types',)),
+ ('Behavior', ('filter_logic',)),
+ ('Values', ('default', 'choices')),
+ ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
+ )
+
+
+class CustomLinkForm(BootstrapMixin, forms.ModelForm):
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_links')
+ )
+
+ class Meta:
+ model = CustomLink
+ fields = '__all__'
+ fieldsets = (
+ ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
+ ('Templates', ('link_text', 'link_url')),
+ )
+ widgets = {
+ 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
+ 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
+ }
+ help_texts = {
+ 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}
. '
+ 'Links which render as empty text will not be displayed.',
+ 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}
.',
+ }
+
+
+class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_links')
+ )
+
+ class Meta:
+ model = ExportTemplate
+ fields = '__all__'
+ fieldsets = (
+ ('Custom Link', ('name', 'content_type', 'description')),
+ ('Template', ('template_code',)),
+ ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
+ )
+ widgets = {
+ 'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
+ }
+
+
+class WebhookForm(BootstrapMixin, forms.ModelForm):
+ content_types = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('webhooks')
+ )
+
+ class Meta:
+ model = Webhook
+ fields = '__all__'
+ fieldsets = (
+ ('Webhook', ('name', 'enabled')),
+ ('Assigned Models', ('content_types',)),
+ ('Events', ('type_create', 'type_update', 'type_delete')),
+ ('HTTP Request', (
+ 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
+ )),
+ ('SSL', ('ssl_verification', 'ca_file_path')),
+ )
+ widgets = {
+ 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
+ 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
+ }
+
+
+class TagForm(BootstrapMixin, forms.ModelForm):
+ slug = SlugField()
+
+ class Meta:
+ model = Tag
+ fields = [
+ 'name', 'slug', 'color', 'description'
+ ]
+ fieldsets = (
+ ('Tag', ('name', 'slug', 'color', 'description')),
+ )
+
+
+class AddRemoveTagsForm(forms.Form):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Add add/remove tags fields
+ self.fields['add_tags'] = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+ self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+
+class ConfigContextForm(BootstrapMixin, forms.ModelForm):
+ regions = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False
+ )
+ site_groups = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False
+ )
+ sites = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False
+ )
+ device_types = DynamicModelMultipleChoiceField(
+ queryset=DeviceType.objects.all(),
+ required=False
+ )
+ roles = DynamicModelMultipleChoiceField(
+ queryset=DeviceRole.objects.all(),
+ required=False
+ )
+ platforms = DynamicModelMultipleChoiceField(
+ queryset=Platform.objects.all(),
+ required=False
+ )
+ cluster_groups = DynamicModelMultipleChoiceField(
+ queryset=ClusterGroup.objects.all(),
+ required=False
+ )
+ clusters = DynamicModelMultipleChoiceField(
+ queryset=Cluster.objects.all(),
+ required=False
+ )
+ tenant_groups = DynamicModelMultipleChoiceField(
+ queryset=TenantGroup.objects.all(),
+ required=False
+ )
+ tenants = DynamicModelMultipleChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+ data = JSONField(
+ label=''
+ )
+
+ class Meta:
+ model = ConfigContext
+ fields = (
+ 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
+ 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+ )
+
+
+class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
+
+ class Meta:
+ model = ImageAttachment
+ fields = [
+ 'name', 'image',
+ ]
+
+
+class JournalEntryForm(BootstrapMixin, forms.ModelForm):
+ comments = CommentField()
+
+ kind = forms.ChoiceField(
+ choices=add_blank_choice(JournalEntryKindChoices),
+ required=False,
+ widget=StaticSelect()
+ )
+
+ class Meta:
+ model = JournalEntry
+ fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
+ widgets = {
+ 'assigned_object_type': forms.HiddenInput,
+ 'assigned_object_id': forms.HiddenInput,
+ }
diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py
new file mode 100644
index 000000000..380b4364c
--- /dev/null
+++ b/netbox/extras/forms/scripts.py
@@ -0,0 +1,30 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin
+
+__all__ = (
+ 'ScriptForm',
+)
+
+
+class ScriptForm(BootstrapMixin, forms.Form):
+ _commit = forms.BooleanField(
+ required=False,
+ initial=True,
+ label="Commit changes",
+ help_text="Commit changes to the database (uncheck for a dry-run)"
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Move _commit to the end of the form
+ commit = self.fields.pop('_commit')
+ self.fields['_commit'] = commit
+
+ @property
+ def requires_input(self):
+ """
+ A boolean indicating whether the form requires user input (ignore the _commit field).
+ """
+ return bool(len(self.fields) > 1)