Просмотр исходного кода

Enable custom templating for webhook request content

Jeremy Stretch 6 лет назад
Родитель
Сommit
99038ffc44

+ 17 - 3
netbox/extras/admin.py

@@ -26,7 +26,7 @@ class WebhookForm(forms.ModelForm):
 
     class Meta:
         model = Webhook
-        exclude = []
+        exclude = ()
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -38,13 +38,27 @@ class WebhookForm(forms.ModelForm):
 @admin.register(Webhook, site=admin_site)
 class WebhookAdmin(admin.ModelAdmin):
     list_display = [
-        'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
-        'type_delete', 'ssl_verification',
+        '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', 'obj_type',
     ]
     form = WebhookForm
+    fieldsets = (
+        (None, {
+            'fields': ('name', 'obj_type', 'enabled')
+        }),
+        ('Events', {
+            'fields': ('type_create', 'type_update', 'type_delete')
+        }),
+        ('HTTP Request', {
+            'fields': ('payload_url', 'http_content_type', 'additional_headers', 'body_template', 'secret')
+        }),
+        ('SSL', {
+            'fields': ('ssl_verification', 'ca_file_path')
+        })
+    )
 
     def models(self, obj):
         return ', '.join([ct.name for ct in obj.obj_type.all()])

+ 0 - 20
netbox/extras/choices.py

@@ -118,23 +118,3 @@ class TemplateLanguageChoices(ChoiceSet):
         LANGUAGE_DJANGO: 10,
         LANGUAGE_JINJA2: 20,
     }
-
-
-#
-# Webhooks
-#
-
-class WebhookContentTypeChoices(ChoiceSet):
-
-    CONTENTTYPE_JSON = 'application/json'
-    CONTENTTYPE_FORMDATA = 'application/x-www-form-urlencoded'
-
-    CHOICES = (
-        (CONTENTTYPE_JSON, 'JSON'),
-        (CONTENTTYPE_FORMDATA, 'Form data'),
-    )
-
-    LEGACY_MAP = {
-        CONTENTTYPE_JSON: 1,
-        CONTENTTYPE_FORMDATA: 2,
-    }

+ 2 - 0
netbox/extras/constants.py

@@ -138,6 +138,8 @@ LOG_LEVEL_CODES = {
     LOG_FAILURE: 'failure',
 }
 
+HTTP_CONTENT_TYPE_JSON = 'application/json'
+
 # Models which support registered webhooks
 WEBHOOK_MODELS = Q(
     Q(app_label='circuits', model__in=[

+ 23 - 0
netbox/extras/migrations/0038_webhook_body_template.py

@@ -0,0 +1,23 @@
+# Generated by Django 2.2.10 on 2020-02-24 20:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0037_configcontexts_clusters'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='webhook',
+            name='body_template',
+            field=models.TextField(blank=True),
+        ),
+        migrations.AlterField(
+            model_name='webhook',
+            name='http_content_type',
+            field=models.CharField(default='application/json', max_length=100),
+        ),
+    ]

+ 15 - 11
netbox/extras/models.py

@@ -52,7 +52,6 @@ class Webhook(models.Model):
     delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
     Each Webhook can be limited to firing only on certain actions or certain object types.
     """
-
     obj_type = models.ManyToManyField(
         to=ContentType,
         related_name='webhooks',
@@ -81,11 +80,15 @@ class Webhook(models.Model):
         verbose_name='URL',
         help_text="A POST will be sent to this URL when the webhook is called."
     )
+    enabled = models.BooleanField(
+        default=True
+    )
     http_content_type = models.CharField(
-        max_length=50,
-        choices=WebhookContentTypeChoices,
-        default=WebhookContentTypeChoices.CONTENTTYPE_JSON,
-        verbose_name='HTTP content type'
+        max_length=100,
+        default=HTTP_CONTENT_TYPE_JSON,
+        verbose_name='HTTP content type',
+        help_text='The complete list of official content types is available '
+                  '<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.'
     )
     additional_headers = JSONField(
         null=True,
@@ -93,6 +96,13 @@ class Webhook(models.Model):
         help_text="User supplied headers which should be added to the request in addition to the HTTP content type. "
                   "Headers are supplied as key/value pairs in a JSON object."
     )
+    body_template = models.TextField(
+        blank=True,
+        help_text='Jinja2 template for a custom request body. If blank, a JSON object or form data representing the '
+                  'change will be included. Available context data includes: <code>event</code>, '
+                  '<code>timestamp</code>, <code>model</code>, <code>username</code>, <code>request_id</code>, and '
+                  '<code>data</code>.'
+    )
     secret = models.CharField(
         max_length=255,
         blank=True,
@@ -101,9 +111,6 @@ class Webhook(models.Model):
                   "the secret as the key. The secret is not transmitted in "
                   "the request."
     )
-    enabled = models.BooleanField(
-        default=True
-    )
     ssl_verification = models.BooleanField(
         default=True,
         verbose_name='SSL verification',
@@ -126,9 +133,6 @@ class Webhook(models.Model):
         return self.name
 
     def clean(self):
-        """
-        Validate model
-        """
         if not self.type_create and not self.type_delete and not self.type_update:
             raise ValidationError(
                 "You must select at least one type: create, update, and/or delete."

+ 0 - 1
netbox/extras/webhooks.py

@@ -1,4 +1,3 @@
-import datetime
 import hashlib
 import hmac
 

+ 30 - 6
netbox/extras/webhooks_worker.py

@@ -1,19 +1,25 @@
 import json
+import logging
 
 import requests
 from django_rq import job
+from jinja2.exceptions import TemplateError
 from rest_framework.utils.encoders import JSONEncoder
 
-from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
+from utilities.utils import render_jinja2
+from .choices import ObjectChangeActionChoices
+from .constants import HTTP_CONTENT_TYPE_JSON
 from .webhooks import generate_signature
 
+logger = logging.getLogger('netbox.webhooks_worker')
+
 
 @job('default')
 def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
     """
     Make a POST request to the defined Webhook
     """
-    payload = {
+    context = {
         'event': dict(ObjectChangeActionChoices)[event].lower(),
         'timestamp': timestamp,
         'model': model_name,
@@ -21,6 +27,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
         'request_id': request_id,
         'data': data
     }
+
+    # Build HTTP headers
     headers = {
         'Content-Type': webhook.http_content_type,
     }
@@ -33,10 +41,22 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
         'headers': headers
     }
 
-    if webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_JSON:
-        params.update({'data': json.dumps(payload, cls=JSONEncoder)})
-    elif webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_FORMDATA:
-        params.update({'data': payload})
+    logger.info(
+        "Sending webhook to {}: {} {}".format(params['url'], context['model'], context['event'])
+    )
+
+    # Construct the request body. If a template has been defined, use it. Otherwise, dump the context as either JSON
+    # or form data.
+    if webhook.body_template:
+        try:
+            params['data'] = render_jinja2(webhook.body_template, context)
+        except TemplateError as e:
+            logger.error("Error rendering request body: {}".format(e))
+            return
+    elif webhook.http_content_type == HTTP_CONTENT_TYPE_JSON:
+        params['data'] = json.dumps(context, cls=JSONEncoder)
+    else:
+        params['data'] = context
 
     prepared_request = requests.Request(**params).prepare()
 
@@ -50,9 +70,13 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
             session.verify = webhook.ca_file_path
         response = session.send(prepared_request)
 
+    logger.debug(params)
+
     if 200 <= response.status_code <= 299:
+        logger.info("Request succeeded; response status {}".format(response.status_code))
         return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
     else:
+        logger.error("Request failed; response status {}: {}".format(response.status_code, response.content))
         raise requests.exceptions.RequestException(
             "Status {} returned with content '{}', webhook FAILED to process.".format(
                 response.status_code, response.content