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/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 1d407846d..ddd9a4154 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -27,6 +27,8 @@ A new ASN range model has been introduced to facilitate the provisioning of new ### Enhancements * [#7947](https://github.com/netbox-community/netbox/issues/7947) - Enable marking IP ranges as fully utilized +* [#8272](https://github.com/netbox-community/netbox/issues/8272) - Support bridge relationships among device type interfaces +* [#8958](https://github.com/netbox-community/netbox/issues/8958) - Changes in background job status can trigger webhooks * [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources * [#9653](https://github.com/netbox-community/netbox/issues/9653) - Enable setting a default platform for device types * [#10374](https://github.com/netbox-community/netbox/issues/10374) - Require unique tenant names & slugs per group (not globally) diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index ee285fa7c..8ef8c4e72 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -22,8 +22,9 @@ def sync_datasource(job_result, *args, **kwargs): # Update the search cache for DataFiles belonging to this source search_backend.cache(datasource.datafiles.iterator()) + job_result.terminate() + except SyncError as e: - job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - job_result.save() + job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED) DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) logging.error(e) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 38cfc8866..eaf4cbd18 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -475,6 +475,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): default=None ) type = ChoiceField(choices=InterfaceTypeChoices) + bridge = NestedInterfaceTemplateSerializer(required=False, allow_null=True) poe_mode = ChoiceField( choices=InterfacePoEModeChoices, required=False, @@ -489,7 +490,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'bridge', 'enabled', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'created', 'last_updated', ] diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 3312d69a8..8fcc35b53 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -810,15 +810,24 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm): class InterfaceTemplateForm(ModularComponentTemplateForm): + bridge = DynamicModelChoiceField( + queryset=InterfaceTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + 'moduletype_id': '$module_type', + } + ) + fieldsets = ( - (None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')), + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')), ('PoE', ('poe_mode', 'poe_type')) ) class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', 'bridge', ] diff --git a/netbox/dcim/migrations/0171_devicetype_add_bridge.py b/netbox/dcim/migrations/0171_devicetype_add_bridge.py new file mode 100644 index 000000000..3e0700a7f --- /dev/null +++ b/netbox/dcim/migrations/0171_devicetype_add_bridge.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.6 on 2023-03-01 13:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0170_configtemplate'), + ] + + operations = [ + migrations.AddField( + model_name='interfacetemplate', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index be17627fb..e2d1cb50d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -350,6 +350,14 @@ class InterfaceTemplate(ModularComponentTemplateModel): default=False, verbose_name='Management only' ) + bridge = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='bridge_interfaces', + null=True, + blank=True, + verbose_name='Bridge interface' + ) poe_mode = models.CharField( max_length=50, choices=InterfacePoEModeChoices, @@ -365,6 +373,19 @@ class InterfaceTemplate(ModularComponentTemplateModel): component_model = Interface + def clean(self): + super().clean() + + if self.bridge: + if self.device_type and self.device_type != self.bridge.device_type: + raise ValidationError({ + 'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type" + }) + if self.module_type and self.module_type != self.bridge.module_type: + raise ValidationError({ + 'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type" + }) + def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -385,6 +406,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): 'mgmt_only': self.mgmt_only, 'label': self.label, 'description': self.description, + 'bridge': self.bridge.name if self.bridge else None, 'poe_mode': self.poe_mode, 'poe_type': self.poe_type, } diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f4e0bad5d..de6d1bc83 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -460,6 +460,20 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) +def update_interface_bridges(device, interface_templates, module=None): + """ + Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned + and applies it to the actual interfaces. + """ + for interface_template in interface_templates.exclude(bridge=None): + interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) + + if interface_template.bridge: + interface.bridge = Interface.objects.get(device=device, name=interface_template.bridge.resolve_name(module=module)) + interface.full_clean() + interface.save() + + class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -854,6 +868,8 @@ class Device(PrimaryModel, ConfigContextModel): self._instantiate_components(self.device_type.devicebaytemplates.all()) # Disable bulk_create to accommodate MPTT self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) + # Interface bridges have to be set after interface instantiation + update_interface_bridges(self, self.device_type.interfacetemplates.all()) # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) @@ -1090,6 +1106,9 @@ class Module(PrimaryModel, ConfigContextModel): update_fields=update_fields ) + # Interface bridges have to be set after interface instantiation + update_interface_bridges(self.device, self.module_type.interfacetemplates, self) + # # Virtual chassis diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 91a37fab3..0536e8940 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -187,7 +187,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.InterfaceTemplate - fields = ('pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions') + fields = ('pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type', 'actions') empty_text = "None" 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/constants.py b/netbox/extras/constants.py index 12ff21b31..d64f02d6b 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,8 +1,16 @@ from django.contrib.contenttypes.models import ContentType -# Webhook content types +# Webhooks HTTP_CONTENT_TYPE_JSON = 'application/json' +WEBHOOK_EVENT_TYPES = { + 'create': 'created', + 'update': 'updated', + 'delete': 'deleted', + 'job_start': 'job_started', + 'job_end': 'job_ended', +} + # Dashboard DEFAULT_DASHBOARD = [ { 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/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index ae49d53be..b10a4644d 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -41,16 +41,16 @@ class Command(BaseCommand): the change_logging context manager (which is bypassed if commit == False). """ try: - with transaction.atomic(): - script.output = script.run(data=data, commit=commit) - job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED) - - if not commit: - raise AbortTransaction() - - except AbortTransaction: - script.log_info("Database changes have been reverted automatically.") - clear_webhooks.send(request) + try: + with transaction.atomic(): + script.output = script.run(data=data, commit=commit) + if not commit: + raise AbortTransaction() + except AbortTransaction: + script.log_info("Database changes have been reverted automatically.") + clear_webhooks.send(request) + job_result.data = ScriptOutputSerializer(script).data + job_result.terminate() except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -58,11 +58,9 @@ class Command(BaseCommand): ) script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") - job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) clear_webhooks.send(request) - finally: job_result.data = ScriptOutputSerializer(script).data - job_result.save() + job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED) logger.info(f"Script completed in {job_result.duration}") diff --git a/netbox/extras/migrations/0088_jobresult_webhooks.py b/netbox/extras/migrations/0088_jobresult_webhooks.py new file mode 100644 index 000000000..112bcca8c --- /dev/null +++ b/netbox/extras/migrations/0088_jobresult_webhooks.py @@ -0,0 +1,23 @@ +# 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), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 1360904dc..d1ca74822 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 @@ -27,7 +26,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT from netbox.models import ChangeLoggedModel from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin, - TagsMixin, + TagsMixin, WebhooksMixin, ) from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -65,15 +64,23 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel): ) type_create = models.BooleanField( default=False, - help_text=_("Call this webhook when a matching object is created.") + 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.") + help_text=_("Triggers when a matching object is updated.") ) type_delete = models.BooleanField( default=False, - help_text=_("Call this webhook when a matching object is deleted.") + help_text=_("Triggers when a matching object is deleted.") + ) + type_job_start = models.BooleanField( + default=False, + 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: @@ -678,19 +689,32 @@ class JobResult(models.Model): """ Record the job's start time and update its status to "running." """ - if self.started is None: - self.started = timezone.now() - self.status = JobResultStatusChoices.STATUS_RUNNING - JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status) + if self.started is not None: + return - def set_status(self, status): + # Start the job + self.started = timezone.now() + self.status = JobResultStatusChoices.STATUS_RUNNING + JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status) + + # Handle webhooks + self.trigger_webhooks(event='job_start') + + def terminate(self, status=JobResultStatusChoices.STATUS_COMPLETED): """ - Helper method to change the status of the job result. If the target status is terminal, the completion - time is also set. + Mark the job as completed, optionally specifying a particular termination status. """ + valid_statuses = JobResultStatusChoices.TERMINAL_STATE_CHOICES + if status not in valid_statuses: + raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}") + + # Mark the job as completed self.status = status - if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES: - self.completed = timezone.now() + self.completed = timezone.now() + JobResult.objects.filter(pk=self.pk).update(status=self.status, completed=self.completed) + + # Handle webhooks + self.trigger_webhooks(event='job_end') @classmethod def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs): @@ -725,6 +749,28 @@ class JobResult(models.Model): return job_result + def trigger_webhooks(self, event): + rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT) + rq_queue = django_rq.get_queue(rq_queue_name, is_async=False) + + # Fetch any webhooks matching this object type and action + webhooks = Webhook.objects.filter( + **{f'type_{event}': True}, + content_types=self.obj_type, + enabled=True + ) + + for webhook in webhooks: + rq_queue.enqueue( + "extras.webhooks_worker.process_webhook", + webhook=webhook, + model_name=self.obj_type.model, + event=event, + data=self.data, + timestamp=str(timezone.now()), + username=self.user.username + ) + class ConfigRevision(models.Model): """ @@ -767,7 +813,7 @@ class ConfigRevision(models.Model): # Custom scripts & reports # -class Script(JobResultsMixin, models.Model): +class Script(JobResultsMixin, WebhooksMixin, models.Model): """ Dummy model used to generate permissions for custom scripts. Does not exist in the database. """ @@ -779,7 +825,7 @@ class Script(JobResultsMixin, models.Model): # Reports # -class Report(JobResultsMixin, models.Model): +class Report(JobResultsMixin, WebhooksMixin, models.Model): """ Dummy model used to generate permissions for reports. Does not exist in the database. """ diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 37c78dd18..0a944a0d2 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -85,8 +85,7 @@ def run_report(job_result, *args, **kwargs): job_result.start() report.run(job_result) except Exception: - job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - job_result.save() + job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED) logging.error(f"Error during execution of report {job_result.name}") finally: # Schedule the next job if an interval has been set @@ -241,28 +240,23 @@ class Report(object): self.pre_run() try: - for method_name in self.test_methods: self.active_test = method_name test_method = getattr(self, method_name) test_method() - if self.failed: self.logger.warning("Report failed") job_result.status = JobResultStatusChoices.STATUS_FAILED else: self.logger.info("Report completed successfully") job_result.status = JobResultStatusChoices.STATUS_COMPLETED - except Exception as e: stacktrace = traceback.format_exc() self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}
{stacktrace}
") logger.error(f"Exception raised during report execution: {e}") - job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - - job_result.data = self._results - job_result.completed = timezone.now() - job_result.save() + job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED) + finally: + job_result.terminate() # Perform any post-run tasks self.post_run() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 313058d57..9b9167e17 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -460,36 +460,28 @@ def run_script(data, request, commit=True, *args, **kwargs): the change_logging context manager (which is bypassed if commit == False). """ try: - with transaction.atomic(): - script.output = script.run(data=data, commit=commit) - job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED) - - if not commit: - raise AbortTransaction() - - except AbortTransaction: - script.log_info("Database changes have been reverted automatically.") - clear_webhooks.send(request) - except AbortScript as e: - script.log_failure( - f"Script aborted with error: {e}" - ) - script.log_info("Database changes have been reverted due to error.") - logger.error(f"Script aborted with error: {e}") - job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - clear_webhooks.send(request) - except Exception as e: - stacktrace = traceback.format_exc() - script.log_failure( - f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```" - ) - script.log_info("Database changes have been reverted due to error.") - logger.error(f"Exception raised during script execution: {e}") - job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - clear_webhooks.send(request) - finally: + try: + with transaction.atomic(): + script.output = script.run(data=data, commit=commit) + if not commit: + raise AbortTransaction() + except AbortTransaction: + script.log_info("Database changes have been reverted automatically.") + clear_webhooks.send(request) job_result.data = ScriptOutputSerializer(script).data - job_result.save() + job_result.terminate() + except Exception as e: + if type(e) is AbortScript: + script.log_failure(f"Script aborted with error: {e}") + logger.error(f"Script aborted with error: {e}") + else: + stacktrace = traceback.format_exc() + script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```") + logger.error(f"Exception raised during script execution: {e}") + script.log_info("Database changes have been reverted due to error.") + job_result.data = ScriptOutputSerializer(script).data + job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED) + clear_webhooks.send(request) logger.info(f"Script completed in {job_result.duration}") 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/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 7e8965182..438231b7e 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -5,8 +5,8 @@ from django.conf import settings from django_rq import job from jinja2.exceptions import TemplateError -from .choices import ObjectChangeActionChoices from .conditions import ConditionSet +from .constants import WEBHOOK_EVENT_TYPES from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') @@ -28,7 +28,7 @@ def eval_conditions(webhook, data): @job('default') -def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id): +def process_webhook(webhook, model_name, event, data, timestamp, username, request_id=None, snapshots=None): """ Make a POST request to the defined Webhook """ @@ -38,14 +38,17 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user # Prepare context data for headers & body templates context = { - 'event': dict(ObjectChangeActionChoices)[event].lower(), + 'event': WEBHOOK_EVENT_TYPES[event], 'timestamp': timestamp, 'model': model_name, 'username': username, 'request_id': request_id, 'data': data, - 'snapshots': snapshots, } + if snapshots: + context.update({ + 'snapshots': snapshots + }) # Build the headers for the HTTP request headers = { 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 %} +