Browse Source

Closes #3451: Add pre-/post-change snapshots to webhooks

Jeremy Stretch 5 years ago
parent
commit
c6641ec1de

+ 14 - 2
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",
+            ...
+        }
     }
 }
 ```

+ 23 - 0
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.

+ 14 - 14
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)

+ 16 - 7
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
             )

+ 3 - 2
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