diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index 1ca6ec191..05ade8666 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -22,11 +22,13 @@ If not selected, the webhook will be inactive. The events which will trigger the webhook. At least one event type must be selected. -| Name | Description | -|-----------|--------------------------------------| -| Creations | A new object has been created | -| Updates | An existing object has been modified | -| Deletions | An object has been deleted | +| Name | Description | +|------------|--------------------------------------| +| Creations | A new object has been created | +| Updates | An existing object has been modified | +| Deletions | An object has been deleted | +| Job starts | A job for an object starts | +| Job ends | A job for an object terminates | ### URL @@ -58,6 +60,10 @@ Jinja2 template for a custom request body, if desired. If not defined, NetBox wi A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. +### Conditions + +A set of [prescribed conditions](../../reference/conditions.md) against which the triggering object will be evaluated. If the conditions are defined but not met by the object, the webhook will not be sent. A webhook that does not define any conditions will _always_ trigger. + ### SSL Verification Controls whether validation of the receiver's SSL certificate is enforced when HTTPS is used. diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5764c66ee..740247df0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -68,9 +68,10 @@ class WebhookSerializer(ValidatedModelSerializer): class Meta: model = Webhook fields = [ - 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', - 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated', + 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', + 'type_job_start', 'type_job_end', 'payload_url', 'enabled', 'http_method', 'http_content_type', + 'additional_headers', 'body_template', 'secret', 'conditions', 'ssl_verification', 'ca_file_path', + 'created', 'last_updated', ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 816406647..4d9947147 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -48,8 +48,8 @@ class WebhookFilterSet(BaseFilterSet): class Meta: model = Webhook fields = [ - 'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', - 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', + 'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'payload_url', + 'enabled', 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index bba585591..156e2e9b0 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -140,6 +140,14 @@ class WebhookBulkEditForm(BulkEditForm): required=False, widget=BulkEditNullBooleanSelect() ) + type_job_start = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + type_job_end = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) http_method = forms.ChoiceField( choices=add_blank_choice(WebhookHttpMethodChoices), required=False, diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index b035c2579..15ed01ac4 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -116,9 +116,9 @@ class WebhookImportForm(CSVModelForm): 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' + 'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'type_job_start', + 'type_job_end', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', + 'secret', 'ssl_verification', 'ca_file_path' ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 114eb1a59..6e0cf7fc7 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -222,7 +222,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), ('Attributes', ('content_type_id', 'http_method', 'enabled')), - ('Events', ('type_create', 'type_update', 'type_delete')), + ('Events', ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), ) content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), @@ -244,19 +244,36 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm): required=False, widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES - ) + ), + label=_('Object creations') ) type_update = forms.NullBooleanField( required=False, widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES - ) + ), + label=_('Object updates') ) type_delete = forms.NullBooleanField( required=False, widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES - ) + ), + label=_('Object deletions') + ) + type_job_start = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Job starts') + ) + type_job_end = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ), + label=_('Job terminations') ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 3e4211532..f4b491c85 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -154,7 +154,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): fieldsets = ( ('Webhook', ('name', 'content_types', 'enabled')), - ('Events', ('type_create', 'type_update', 'type_delete')), + ('Events', ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', )), @@ -169,6 +169,8 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): 'type_create': 'Creations', 'type_update': 'Updates', 'type_delete': 'Deletions', + 'type_job_start': 'Job executions', + 'type_job_end': 'Job terminations', } widgets = { 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), diff --git a/netbox/extras/migrations/0088_jobresult_webhooks.py b/netbox/extras/migrations/0088_jobresult_webhooks.py new file mode 100644 index 000000000..f0dc570a9 --- /dev/null +++ b/netbox/extras/migrations/0088_jobresult_webhooks.py @@ -0,0 +1,38 @@ +# Generated by Django 4.1.7 on 2023-02-28 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0087_dashboard'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='type_job_end', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='webhook', + name='type_job_start', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='webhook', + name='type_create', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='webhook', + name='type_delete', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='webhook', + name='type_update', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 1360904dc..e6d209941 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -5,7 +5,6 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import MinValueValidator, ValidationError from django.db import models @@ -64,16 +63,24 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel): unique=True ) type_create = models.BooleanField( - default=False, - help_text=_("Call this webhook when a matching object is created.") + default=True, + help_text=_("Triggers when a matching object is created.") ) type_update = models.BooleanField( - default=False, - help_text=_("Call this webhook when a matching object is updated.") + default=True, + help_text=_("Triggers when a matching object is updated.") ) type_delete = models.BooleanField( + default=True, + help_text=_("Triggers when a matching object is deleted.") + ) + type_job_start = models.BooleanField( default=False, - help_text=_("Call this webhook when a matching object is deleted.") + help_text=_("Triggers when a job for a matching object is started.") + ) + type_job_end = models.BooleanField( + default=False, + help_text=_("Triggers when a job for a matching object terminates.") ) payload_url = models.CharField( max_length=500, @@ -159,8 +166,12 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel): super().clean() # At least one action type must be selected - if not self.type_create and not self.type_delete and not self.type_update: - raise ValidationError("At least one type must be selected: create, update, and/or delete.") + if not any([ + self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end + ]): + raise ValidationError( + "At least one event type must be selected: create, update, delete, job_start, and/or job_end." + ) if self.conditions: try: diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 5991203f2..31b6f02c3 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -146,6 +146,12 @@ class WebhookTable(NetBoxTable): type_delete = columns.BooleanColumn( verbose_name='Delete' ) + type_job_start = columns.BooleanColumn( + verbose_name='Job start' + ) + type_job_end = columns.BooleanColumn( + verbose_name='Job end' + ) ssl_validation = columns.BooleanColumn( verbose_name='SSL Validation' ) @@ -153,12 +159,13 @@ class WebhookTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Webhook fields = ( - 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', - 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated', + 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', + 'type_job_start', 'type_job_end', 'http_method', 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', + 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', - 'payload_url', + 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start', + 'type_job_end', 'http_method', 'payload_url', ) diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 3c8899b5e..0c161cfae 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -89,12 +89,16 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): @classmethod def setUpTestData(cls): - content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device']) webhooks = ( Webhook( name='Webhook 1', type_create=True, + type_update=False, + type_delete=False, + type_job_start=False, + type_job_end=False, payload_url='http://example.com/?1', enabled=True, http_method='GET', @@ -102,7 +106,11 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): ), Webhook( name='Webhook 2', + type_create=False, type_update=True, + type_delete=False, + type_job_start=False, + type_job_end=False, payload_url='http://example.com/?2', enabled=True, http_method='POST', @@ -110,26 +118,56 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): ), Webhook( name='Webhook 3', + type_create=False, + type_update=False, type_delete=True, + type_job_start=False, + type_job_end=False, payload_url='http://example.com/?3', enabled=False, http_method='PATCH', ssl_verification=False, ), + Webhook( + name='Webhook 4', + type_create=False, + type_update=False, + type_delete=False, + type_job_start=True, + type_job_end=False, + payload_url='http://example.com/?4', + enabled=False, + http_method='PATCH', + ssl_verification=False, + ), + Webhook( + name='Webhook 5', + type_create=False, + type_update=False, + type_delete=False, + type_job_start=False, + type_job_end=True, + payload_url='http://example.com/?5', + enabled=False, + http_method='PATCH', + ssl_verification=False, + ), ) Webhook.objects.bulk_create(webhooks) webhooks[0].content_types.add(content_types[0]) webhooks[1].content_types.add(content_types[1]) webhooks[2].content_types.add(content_types[2]) + webhooks[3].content_types.add(content_types[3]) + webhooks[4].content_types.add(content_types[4]) def test_name(self): params = {'name': ['Webhook 1', 'Webhook 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_content_types(self): - params = {'content_types': 'dcim.site'} + params = {'content_types': 'dcim.region'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_type_create(self): @@ -144,6 +182,14 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): params = {'type_delete': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_type_job_start(self): + params = {'type_job_start': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_type_job_end(self): + params = {'type_job_end': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_enabled(self): params = {'enabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index 78aaaa105..11fdcafb9 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -40,6 +40,14 @@ Delete {% checkmark object.type_delete %} + + Job start + {% checkmark object.type_job_start %} + + + Job end + {% checkmark object.type_job_end %} +