diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 845a6745d..d5b12fb85 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -38,7 +38,8 @@ The following data is available as context for Jinja2 templates: * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). * `username` - The name of the user account associated with the change. * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. -* `data` - A serialized representation of the object _after_ the change was made. This is typically equivalent to the model's representation in NetBox's REST API. +* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. +* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. ### Default Request Body @@ -47,7 +48,7 @@ If no body template is specified, the request body will be populated with a JSON ```no-highlight { "event": "created", - "timestamp": "2020-02-25 15:10:26.010582+00:00", + "timestamp": "2021-03-09 17:55:33.968016+00:00", "model": "site", "username": "jstretch", "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", @@ -62,6 +63,17 @@ If no body template is specified, the request body will be populated with a JSON }, "region": null, ... + }, + "snapshots": { + "prechange": null, + "postchange": { + "created": "2021-03-09", + "last_updated": "2021-03-09T17:55:33.851Z", + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + } } } ``` diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 0bccab01e..69023ad83 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -13,6 +13,29 @@ later will be required. Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0. +#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) + +In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include a pre- and post-change representation of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: + +```json +"snapshots": { + "prechange": { + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + }, + "postchange": { + "name": "Site 2", + "slug": "site-2", + "status": "planned", + ... + } +} +``` + +Note: The pre-change snapshot for an object creation will always be null, as will the post-change snapshot for an object deletion. + #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648)) Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination. diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index d8f30e26e..eff9cdb97 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -56,10 +56,10 @@ class WebhookTest(APITestCase): # Verify that a job was queued for the object creation webhook self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] - self.assertEqual(job.args[0], Webhook.objects.get(type_create=True)) - self.assertEqual(job.args[1]['id'], response.data['id']) - self.assertEqual(job.args[2], 'site') - self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True)) + self.assertEqual(job.kwargs['data']['id'], response.data['id']) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) def test_enqueue_webhook_update(self): # Update an object via the REST API @@ -75,10 +75,10 @@ class WebhookTest(APITestCase): # Verify that a job was queued for the object update webhook self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] - self.assertEqual(job.args[0], Webhook.objects.get(type_update=True)) - self.assertEqual(job.args[1]['id'], site.pk) - self.assertEqual(job.args[2], 'site') - self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True)) + self.assertEqual(job.kwargs['data']['id'], site.pk) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) def test_enqueue_webhook_delete(self): # Delete an object via the REST API @@ -91,10 +91,10 @@ class WebhookTest(APITestCase): # Verify that a job was queued for the object update webhook self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] - self.assertEqual(job.args[0], Webhook.objects.get(type_delete=True)) - self.assertEqual(job.args[1]['id'], site.pk) - self.assertEqual(job.args[2], 'site') - self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True)) + self.assertEqual(job.kwargs['data']['id'], site.pk) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) def test_webhooks_worker(self): @@ -116,7 +116,7 @@ class WebhookTest(APITestCase): # Validate the outgoing request body body = json.loads(request.body) self.assertEqual(body['event'], 'created') - self.assertEqual(body['timestamp'], job.args[4]) + self.assertEqual(body['timestamp'], job.kwargs['timestamp']) self.assertEqual(body['model'], 'site') self.assertEqual(body['username'], 'testuser') self.assertEqual(body['request_id'], str(request_id)) @@ -138,4 +138,4 @@ class WebhookTest(APITestCase): # Patch the Session object with our dummy_send() method, then process the webhook for sending with patch.object(Session, 'send', dummy_send) as mock_send: - process_webhook(*job.args) + process_webhook(**job.kwargs) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index f062e5752..bd645aca9 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -6,6 +6,7 @@ from django.utils import timezone from django_rq import get_queue from utilities.api import get_serializer_for_model +from utilities.utils import serialize_object from .choices import * from .models import Webhook from .registry import registry @@ -44,6 +45,7 @@ def enqueue_webhooks(instance, user, request_id, action): webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True}) if webhooks.exists(): + # Get the Model's API serializer class and serialize the object serializer_class = get_serializer_for_model(instance.__class__) serializer_context = { @@ -51,16 +53,23 @@ def enqueue_webhooks(instance, user, request_id, action): } serializer = serializer_class(instance, context=serializer_context) + # Gather pre- and post-change snapshots + snapshots = { + 'prechange': getattr(instance, '_prechange_snapshot', None), + 'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None, + } + # Enqueue the webhooks webhook_queue = get_queue('default') for webhook in webhooks: webhook_queue.enqueue( "extras.webhooks_worker.process_webhook", - webhook, - serializer.data, - instance._meta.model_name, - action, - str(timezone.now()), - user.username, - request_id + webhook=webhook, + model_name=instance._meta.model_name, + event=action, + data=serializer.data, + snapshots=snapshots, + timestamp=str(timezone.now()), + username=user.username, + request_id=request_id ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index e47478f9f..ce63e14ce 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -12,7 +12,7 @@ logger = logging.getLogger('netbox.webhooks_worker') @job('default') -def process_webhook(webhook, data, model_name, event, timestamp, username, request_id): +def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id): """ Make a POST request to the defined Webhook """ @@ -22,7 +22,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'model': model_name, 'username': username, 'request_id': request_id, - 'data': data + 'data': data, + 'snapshots': snapshots, } # Build the headers for the HTTP request