diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 3cf1794f2..dae21c2c9 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,60 +1,6 @@ -from django import forms from django.contrib import admin -from django.contrib.contenttypes.models import ContentType -from utilities.forms import ContentTypeMultipleChoiceField, LaxURLField -from .models import JobResult, Webhook -from .utils import FeatureQuery - - -# -# Webhooks -# - -class WebhookForm(forms.ModelForm): - content_types = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks') - ) - payload_url = LaxURLField( - label='URL' - ) - - class Meta: - model = Webhook - exclude = () - - -@admin.register(Webhook) -class WebhookAdmin(admin.ModelAdmin): - list_display = [ - 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete', - 'ssl_verification', - ] - list_filter = [ - 'enabled', 'type_create', 'type_update', 'type_delete', 'content_types', - ] - form = WebhookForm - fieldsets = ( - (None, { - 'fields': ('name', 'content_types', 'enabled') - }), - ('Events', { - 'fields': ('type_create', 'type_update', 'type_delete') - }), - ('HTTP Request', { - 'fields': ( - 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - ), - 'classes': ('monospace',) - }), - ('SSL', { - 'fields': ('ssl_verification', 'ca_file_path') - }) - ) - - def models(self, obj): - return ', '.join([ct.name for ct in obj.content_types.all()]) +from .models import JobResult # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 7a8a37ff9..02b41e7e1 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -268,6 +268,129 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form): ) +# +# 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 # diff --git a/netbox/extras/migrations/0061_extras_change_logging.py b/netbox/extras/migrations/0061_extras_change_logging.py index cd8531f89..4ee532fd5 100644 --- a/netbox/extras/migrations/0061_extras_change_logging.py +++ b/netbox/extras/migrations/0061_extras_change_logging.py @@ -38,4 +38,14 @@ class Migration(migrations.Migration): 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/models.py b/netbox/extras/models/models.py index 6d38ed9c3..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() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 66e623360..77304edc6 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -86,6 +86,27 @@ class ExportTemplateTable(BaseTable): ) +# +# Webhooks +# + +class WebhookTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + + 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 # diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 7a00f5f73..704f6f8bb 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -120,6 +120,49 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +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 6404f2677..4dafb25f2 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -42,6 +42,18 @@ urlpatterns = [ path('export-templates//changelog/', views.ObjectChangeLogView.as_view(), name='exporttemplate_changelog', kwargs={'model': models.ExportTemplate}), + # Webhooks + path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), + path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), + path('webhooks/import/', views.WebhookBulkImportView.as_view(), name='webhook_import'), + path('webhooks/edit/', views.WebhookBulkEditView.as_view(), name='webhook_bulk_edit'), + path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'), + path('webhooks//', views.WebhookView.as_view(), name='webhook'), + path('webhooks//edit/', views.WebhookEditView.as_view(), name='webhook_edit'), + path('webhooks//delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'), + path('webhooks//changelog/', views.ObjectChangeLogView.as_view(), name='webhook_changelog', + kwargs={'model': models.Webhook}), + # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), path('tags/add/', views.TagEditView.as_view(), name='tag_add'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 28089bd39..10bf2f6c8 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -149,6 +149,49 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.ExportTemplateTable +# +# Webhooks +# + +class WebhookListView(generic.ObjectListView): + queryset = Webhook.objects.all() + filterset = filtersets.WebhookFilterSet + filterset_form = forms.WebhookFilterForm + table = tables.WebhookTable + + +class WebhookView(generic.ObjectView): + queryset = Webhook.objects.all() + + +class WebhookEditView(generic.ObjectEditView): + queryset = Webhook.objects.all() + model_form = forms.WebhookForm + + +class WebhookDeleteView(generic.ObjectDeleteView): + queryset = Webhook.objects.all() + + +class WebhookBulkImportView(generic.BulkImportView): + queryset = Webhook.objects.all() + model_form = forms.WebhookCSVForm + table = tables.WebhookTable + + +class WebhookBulkEditView(generic.BulkEditView): + queryset = Webhook.objects.all() + filterset = filtersets.WebhookFilterSet + table = tables.WebhookTable + form = forms.WebhookBulkEditForm + + +class WebhookBulkDeleteView(generic.BulkDeleteView): + queryset = Webhook.objects.all() + filterset = filtersets.WebhookFilterSet + table = tables.WebhookTable + + # # Tags # diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 64999e2f5..f0694f33a 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -3,7 +3,7 @@ {% load plugins %} {% block breadcrumbs %} - + {% endblock %} diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html new file mode 100644 index 000000000..9546952e5 --- /dev/null +++ b/netbox/templates/extras/webhook.html @@ -0,0 +1,165 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + + +{% endblock %} + +{% block content %} +
+
+
+
+ Webhook +
+
+ + + + + + + + + +
Name{{ object.name }}
Enabled + {% if object.enabled %} + + {% else %} + + {% endif %} +
+
+
+
+
+ Events +
+
+ + + + + + + + + + + + + +
Create + {% if object.type_create %} + + {% else %} + + {% endif %} +
Update + {% if object.type_create %} + + {% else %} + + {% endif %} +
Delete + {% if object.type_create %} + + {% else %} + + {% endif %} +
+
+
+
+
+ HTTP Request +
+
+ + + + + + + + + + + + + + + + + +
HTTP Method{{ object.get_http_method_display }}
Payload URL{{ object.payload_url }}
HTTP Content Type{{ object.http_content_type }}
Secret{{ object.secret|placeholder }}
+
+
+
+
+ SSL +
+
+ + + + + + + + + +
SSL Verification + {% if object.ssl_verification %} + + {% else %} + + {% endif %} +
CA File Path + {% if object.ca_file_path %} + {{ object.ca_file_path }} + {% else %} + &mdash + {% endif %} +
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Assigned Models +
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
+
+
+ Additional Headers +
+
+
{{ object.additional_headers }}
+
+
+
+
+ Body Template +
+
+
{{ object.body_template }}
+
+
+ {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/templatetags/nav.py b/netbox/utilities/templatetags/nav.py index 884a5971b..01c611a1b 100644 --- a/netbox/utilities/templatetags/nav.py +++ b/netbox/utilities/templatetags/nav.py @@ -287,6 +287,8 @@ OTHER_MENU = Menu( add_url=None, import_url=None), MenuItem(label="Journal Entries", url="extras:journalentry_list", add_url=None, import_url=None), + MenuItem(label="Webhooks", url="extras:webhook_list", + add_url="extras:webhook_add", import_url="extras:webhook_import"), ), ), MenuGroup(