{{ 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 }}
.',
- }
-
-
-@admin.register(CustomLink)
-class CustomLinkAdmin(admin.ModelAdmin):
- fieldsets = (
- ('Custom Link', {
- 'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
- }),
- ('Templates', {
- 'fields': ('link_text', 'link_url'),
- 'classes': ('monospace',)
- })
- )
- list_display = [
- 'name', 'content_type', 'group_name', 'weight',
- ]
- list_filter = [
- 'content_type',
- ]
- form = CustomLinkForm
-
-
-#
-# Export templates
-#
-
-class ExportTemplateForm(forms.ModelForm):
- content_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=FeatureQuery('custom_links')
- )
-
- class Meta:
- model = ExportTemplate
- exclude = []
-
-
-@admin.register(ExportTemplate)
-class ExportTemplateAdmin(admin.ModelAdmin):
- fieldsets = (
- ('Export Template', {
- 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment')
- }),
- ('Content', {
- 'fields': ('template_code',),
- 'classes': ('monospace',)
- })
- )
- list_display = [
- 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
- ]
- list_filter = [
- 'content_type',
- ]
- form = ExportTemplateForm
+from .models import JobResult
#
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index c8af566b0..02b41e7e1 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -8,12 +8,13 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
- CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
- JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
+ CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm,
+ CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
+ StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
-from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
+from .models import *
from .utils import FeatureQuery
@@ -21,6 +22,379 @@ 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"
+ )
+
+ class Meta:
+ model = CustomField
+ fields = (
+ 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
+ '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 = [
+ ['type', 'content_types'],
+ ['weight', 'required'],
+ ]
+ content_types = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields')
+ )
+ type = forms.MultipleChoiceField(
+ choices=CustomFieldTypeChoices,
+ required=False,
+ widget=StaticSelect2Multiple()
+ )
+ weight = forms.IntegerField(
+ required=False
+ )
+ required = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ 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')),
+ )
+
+
+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=StaticSelect2()
+ )
+
+ class Meta:
+ nullable_fields = []
+
+
+class CustomLinkFilterForm(BootstrapMixin, forms.Form):
+ field_groups = [
+ ['content_type'],
+ ['weight', 'new_window'],
+ ]
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields')
+ )
+ weight = forms.IntegerField(
+ required=False
+ )
+ new_window = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ 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')),
+ )
+
+
+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 = [
+ ['content_type', 'mime_type'],
+ ['file_extension', 'as_attachment'],
+ ]
+ content_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields')
+ )
+ mime_type = forms.CharField(
+ required=False
+ )
+ file_extension = forms.CharField(
+ required=False
+ )
+ as_attachment = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ 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')),
+ )
+
+
+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 = [
+ ['content_types', 'http_method'],
+ ['enabled', 'type_create', 'type_update', 'type_delete'],
+ ]
+ content_types = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=FeatureQuery('custom_fields')
+ )
+ http_method = forms.MultipleChoiceField(
+ choices=WebhookHttpMethodChoices,
+ required=False,
+ widget=StaticSelect2Multiple()
+ )
+ enabled = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ type_create = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ type_update = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ type_delete = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+#
+# Custom field models
+#
+
class CustomFieldsMixin:
"""
Extend a Form to include custom field support.
diff --git a/netbox/extras/migrations/0061_extras_change_logging.py b/netbox/extras/migrations/0061_extras_change_logging.py
new file mode 100644
index 000000000..4ee532fd5
--- /dev/null
+++ b/netbox/extras/migrations/0061_extras_change_logging.py
@@ -0,0 +1,51 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0060_customlink_button_class'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='customfield',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='customfield',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='customlink',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='customlink',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='created',
+ field=models.DateField(auto_now_add=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, null=True),
+ ),
+ ]
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 60c6adce9..4ac70bb58 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -6,11 +6,12 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
+from django.urls import reverse
from django.utils.safestring import mark_safe
from extras.choices import *
-from extras.utils import FeatureQuery
-from netbox.models import BigIDModel
+from extras.utils import FeatureQuery, extras_features
+from netbox.models import ChangeLoggedModel
from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice,
)
@@ -29,7 +30,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
-class CustomField(BigIDModel):
+@extras_features('webhooks')
+class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
@@ -114,6 +116,9 @@ class CustomField(BigIDModel):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
+ def get_absolute_url(self):
+ return reverse('extras:customfield', args=[self.pk])
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index ab9cbe9f3..58d2d857e 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -36,7 +36,8 @@ __all__ = (
# Webhooks
#
-class Webhook(BigIDModel):
+@extras_features('webhooks')
+class Webhook(ChangeLoggedModel):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
@@ -129,6 +130,9 @@ class Webhook(BigIDModel):
def __str__(self):
return self.name
+ def get_absolute_url(self):
+ return reverse('extras:webhook', args=[self.pk])
+
def clean(self):
super().clean()
@@ -171,7 +175,8 @@ class Webhook(BigIDModel):
# Custom links
#
-class CustomLink(BigIDModel):
+@extras_features('webhooks')
+class CustomLink(ChangeLoggedModel):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context.
@@ -221,12 +226,16 @@ class CustomLink(BigIDModel):
def __str__(self):
return self.name
+ def get_absolute_url(self):
+ return reverse('extras:customlink', args=[self.pk])
+
#
# Export templates
#
-class ExportTemplate(BigIDModel):
+@extras_features('webhooks')
+class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
@@ -269,6 +278,9 @@ class ExportTemplate(BigIDModel):
def __str__(self):
return f"{self.content_type}: {self.name}"
+ def get_absolute_url(self):
+ return reverse('extras:exporttemplate', args=[self.pk])
+
def clean(self):
super().clean()
diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py
index 633261448..a3c103773 100644
--- a/netbox/extras/tables.py
+++ b/netbox/extras/tables.py
@@ -4,7 +4,7 @@ from django.conf import settings
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn,
)
-from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem
+from .models import *
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
@@ -28,6 +28,96 @@ OBJECTCHANGE_REQUEST_ID = """
"""
+#
+# Custom fields
+#
+
+class CustomFieldTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ required = BooleanColumn()
+
+ class Meta(BaseTable.Meta):
+ model = CustomField
+ fields = (
+ 'pk', 'name', 'label', 'type', 'required', 'weight', 'default', 'description', 'filter_logic', 'choices',
+ )
+ default_columns = ('pk', 'name', 'label', 'type', 'required', 'description')
+
+
+#
+# Custom links
+#
+
+class CustomLinkTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ content_type = ContentTypeColumn()
+ new_window = BooleanColumn()
+
+ class Meta(BaseTable.Meta):
+ model = CustomLink
+ fields = (
+ 'pk', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window',
+ )
+ default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
+
+
+#
+# Export templates
+#
+
+class ExportTemplateTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ content_type = ContentTypeColumn()
+ as_attachment = BooleanColumn()
+
+ class Meta(BaseTable.Meta):
+ model = ExportTemplate
+ fields = (
+ 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
+ )
+ default_columns = (
+ 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
+ )
+
+
+#
+# Webhooks
+#
+
+class WebhookTable(BaseTable):
+ pk = ToggleColumn()
+ name = tables.Column(
+ linkify=True
+ )
+ enabled = BooleanColumn()
+ type_create = BooleanColumn()
+ type_update = BooleanColumn()
+ type_delete = BooleanColumn()
+
+ class Meta(BaseTable.Meta):
+ model = Webhook
+ fields = (
+ 'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url',
+ 'secret', 'ssl_validation', 'ca_file_path',
+ )
+ default_columns = (
+ 'pk', 'name', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'payload_url',
+ )
+
+
+#
+# Tags
+#
+
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 286fa7613..704f6f8bb 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -6,11 +6,163 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from dcim.models import Site
-from extras.choices import ObjectChangeActionChoices
-from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag
+from extras.choices import *
+from extras.models import *
from utilities.testing import ViewTestCases, TestCase
+class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = CustomField
+
+ @classmethod
+ def setUpTestData(cls):
+
+ site_ct = ContentType.objects.get_for_model(Site)
+ custom_fields = (
+ CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
+ CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
+ CustomField(name='field3', label='Field 3', type=CustomFieldTypeChoices.TYPE_TEXT),
+ )
+ for customfield in custom_fields:
+ customfield.save()
+ customfield.content_types.add(site_ct)
+
+ cls.form_data = {
+ 'name': 'field_x',
+ 'label': 'Field X',
+ 'type': 'text',
+ 'content_types': [site_ct.pk],
+ 'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
+ 'default': None,
+ 'weight': 200,
+ 'required': True,
+ }
+
+ cls.csv_data = (
+ "name,label,type,content_types,weight,filter_logic",
+ "field4,Field 4,text,dcim.site,100,exact",
+ "field5,Field 5,text,dcim.site,100,exact",
+ "field6,Field 6,text,dcim.site,100,exact",
+ )
+
+ cls.bulk_edit_data = {
+ 'required': True,
+ 'weight': 200,
+ }
+
+
+class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = CustomLink
+
+ @classmethod
+ def setUpTestData(cls):
+
+ site_ct = ContentType.objects.get_for_model(Site)
+ CustomLink.objects.bulk_create((
+ CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'),
+ CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'),
+ CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'),
+ ))
+
+ cls.form_data = {
+ 'name': 'Custom Link X',
+ 'content_type': site_ct.pk,
+ 'weight': 100,
+ 'button_class': CustomLinkButtonClassChoices.CLASS_DEFAULT,
+ 'link_text': 'Link X',
+ 'link_url': 'http://example.com/?x'
+ }
+
+ cls.csv_data = (
+ "name,content_type,weight,button_class,link_text,link_url",
+ "Custom Link 4,dcim.site,100,primary,Link 4,http://exmaple.com/?4",
+ "Custom Link 5,dcim.site,100,primary,Link 5,http://exmaple.com/?5",
+ "Custom Link 6,dcim.site,100,primary,Link 6,http://exmaple.com/?6",
+ )
+
+ cls.bulk_edit_data = {
+ 'button_class': CustomLinkButtonClassChoices.CLASS_INFO,
+ 'weight': 200,
+ }
+
+
+class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = ExportTemplate
+
+ @classmethod
+ def setUpTestData(cls):
+
+ site_ct = ContentType.objects.get_for_model(Site)
+ TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
+ ExportTemplate.objects.bulk_create((
+ ExportTemplate(name='Export Template 1', content_type=site_ct, template_code=TEMPLATE_CODE),
+ ExportTemplate(name='Export Template 2', content_type=site_ct, template_code=TEMPLATE_CODE),
+ ExportTemplate(name='Export Template 3', content_type=site_ct, template_code=TEMPLATE_CODE),
+ ))
+
+ cls.form_data = {
+ 'name': 'Export Template X',
+ 'content_type': site_ct.pk,
+ 'template_code': TEMPLATE_CODE,
+ }
+
+ cls.csv_data = (
+ "name,content_type,template_code",
+ f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
+ f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
+ f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
+ )
+
+ cls.bulk_edit_data = {
+ 'mime_type': 'text/html',
+ 'file_extension': 'html',
+ 'as_attachment': True,
+ }
+
+
+class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = Webhook
+
+ @classmethod
+ def setUpTestData(cls):
+
+ site_ct = ContentType.objects.get_for_model(Site)
+ webhooks = (
+ Webhook(name='Webhook 1', payload_url='http://example.com/?1', type_create=True, http_method='POST'),
+ Webhook(name='Webhook 2', payload_url='http://example.com/?2', type_create=True, http_method='POST'),
+ Webhook(name='Webhook 3', payload_url='http://example.com/?3', type_create=True, http_method='POST'),
+ )
+ for webhook in webhooks:
+ webhook.save()
+ webhook.content_types.add(site_ct)
+
+ cls.form_data = {
+ 'name': 'Webhook X',
+ 'content_types': [site_ct.pk],
+ 'type_create': False,
+ 'type_update': True,
+ 'type_delete': True,
+ 'payload_url': 'http://example.com/?x',
+ 'http_method': 'GET',
+ 'http_content_type': 'application/foo',
+ }
+
+ cls.csv_data = (
+ "name,content_types,type_create,payload_url,http_method,http_content_type",
+ "Webhook 4,dcim.site,True,http://example.com/?4,GET,application/json",
+ "Webhook 5,dcim.site,True,http://example.com/?5,GET,application/json",
+ "Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json",
+ )
+
+ cls.bulk_edit_data = {
+ 'enabled': False,
+ 'type_create': False,
+ 'type_update': True,
+ 'type_delete': True,
+ 'http_method': 'GET',
+ }
+
+
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Tag
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index 9ec19d215..4dafb25f2 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -1,12 +1,59 @@
from django.urls import path
-from extras import views
-from extras.models import ConfigContext, JournalEntry, Tag
+from extras import models, views
app_name = 'extras'
urlpatterns = [
+ # Custom fields
+ path('custom-fields/', views.CustomFieldListView.as_view(), name='customfield_list'),
+ path('custom-fields/add/', views.CustomFieldEditView.as_view(), name='customfield_add'),
+ path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'),
+ path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'),
+ path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
+ path('custom-fields/Name | +{{ object.name }} | +
---|---|
Label | +{{ object.label|placeholder }} | +
Type | +{{ object.get_type_display }} | +
Description | +{{ object.description|placeholder }} | +
Required | ++ {% if object.required %} + + {% else %} + + {% endif %} + | +
Weight | +{{ object.weight }} | +
Default Value | +{{ object.default }} | +
---|---|
Choices | +{{ object.choices|placeholder }} | +
Filter Logic | +{{ object.get_filter_logic_display }} | +
{{ ct }} | +
Minimum Value | +{{ object.validation_minimum|placeholder }} | +
---|---|
Maximum Value | +{{ object.validation_maximum|placeholder }} | +
Regular Expression | +
+ {% if object.validation_regex %}
+ {{ object.validation_regex }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
Content Type | +{{ object.content_type }} | +
---|---|
Name | +{{ object.name }} | +
Group Name | +{{ object.group_name|placeholder }} | +
Weight | +{{ object.weight }} | +
Button Class | +{{ object.get_button_class_display }} | +
New Window | ++ {% if object.new_window %} + + {% else %} + + {% endif %} + | +
{{ object.link_text }}+
{{ object.link_url }}+
Content Type | +{{ object.content_type }} | +
---|---|
Name | +{{ object.name }} | +
Description | +{{ object.description|placeholder }} | +
MIME Type | +{{ object.mime_type|placeholder }} | +
File Extension | +{{ object.file_extension|placeholder }} | +
Attachment | ++ {% if object.as_attachment %} + + {% else %} + + {% endif %} + | +
{{ object.template_code }}+
Name | +{{ object.name }} | +
---|---|
Enabled | ++ {% if object.enabled %} + + {% else %} + + {% endif %} + | +
Create | ++ {% if object.type_create %} + + {% else %} + + {% endif %} + | +
---|---|
Update | ++ {% if object.type_create %} + + {% else %} + + {% endif %} + | +
Delete | ++ {% if object.type_create %} + + {% else %} + + {% endif %} + | +
HTTP Method | +{{ object.get_http_method_display }} | +
---|---|
Payload URL | +{{ object.payload_url }} |
+
HTTP Content Type | +{{ object.http_content_type }} | +
Secret | +{{ object.secret|placeholder }} | +
SSL Verification | ++ {% if object.ssl_verification %} + + {% else %} + + {% endif %} + | +
---|---|
CA File Path | +
+ {% if object.ca_file_path %}
+ {{ object.ca_file_path }}
+ {% else %}
+ &mdash
+ {% endif %}
+ |
+
{{ ct }} | +
{{ object.additional_headers }}+
{{ object.body_template }}+