Repository: netbox-community/netbox

Issue:
Move & rename `extras.webhooks_worker.process_webhook`
### Proposed Changes

Move the existing `process_webhook()` function, which is responsible for transmitting queued webhooks, from `extras.webhooks_worker` to `extras.webhooks`. I'll also propose renaming it to `send_webhook()`, which IMO better conveys its function.

We can retain a proxy for this function in its current location to provide backward compatibility until the next release. This will ensure any currently queued webhooks continue to be processed successfully after an upgrade.

### Justification

Cleanup in the wake of #14132 and clarification of the function's purpose.


Discussion:

Resolving patch:
diff --git a/netbox/extras/events.py b/netbox/extras/events.py
--- a/netbox/extras/events.py
+++ b/netbox/extras/events.py
@@ -108,7 +108,7 @@ def process_event_rules(event_rules, model_name, event, data, username, snapshot
 
             # Enqueue the task
             rq_queue.enqueue(
-                "extras.webhooks_worker.process_webhook",
+                "extras.webhooks.send_webhook",
                 **params
             )
 
diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py
--- a/netbox/extras/webhooks.py
+++ b/netbox/extras/webhooks.py
@@ -1,5 +1,15 @@
 import hashlib
 import hmac
+import logging
+
+import requests
+from django.conf import settings
+from django_rq import job
+from jinja2.exceptions import TemplateError
+
+from .constants import WEBHOOK_EVENT_TYPES
+
+logger = logging.getLogger('netbox.webhooks')
 
 
 def generate_signature(request_body, secret):
@@ -12,3 +22,79 @@ def generate_signature(request_body, secret):
         digestmod=hashlib.sha512
     )
     return hmac_prep.hexdigest()
+
+
+@job('default')
+def send_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
+    """
+    Make a POST request to the defined Webhook
+    """
+    webhook = event_rule.action_object
+
+    # Prepare context data for headers & body templates
+    context = {
+        'event': WEBHOOK_EVENT_TYPES[event],
+        'timestamp': timestamp,
+        'model': model_name,
+        'username': username,
+        'request_id': request_id,
+        'data': data,
+    }
+    if snapshots:
+        context.update({
+            'snapshots': snapshots
+        })
+
+    # Build the headers for the HTTP request
+    headers = {
+        'Content-Type': webhook.http_content_type,
+    }
+    try:
+        headers.update(webhook.render_headers(context))
+    except (TemplateError, ValueError) as e:
+        logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}")
+        raise e
+
+    # Render the request body
+    try:
+        body = webhook.render_body(context)
+    except TemplateError as e:
+        logger.error(f"Error rendering request body for webhook {webhook}: {e}")
+        raise e
+
+    # Prepare the HTTP request
+    params = {
+        'method': webhook.http_method,
+        'url': webhook.render_payload_url(context),
+        'headers': headers,
+        'data': body.encode('utf8'),
+    }
+    logger.info(
+        f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})"
+    )
+    logger.debug(params)
+    try:
+        prepared_request = requests.Request(**params).prepare()
+    except requests.exceptions.RequestException as e:
+        logger.error(f"Error forming HTTP request: {e}")
+        raise e
+
+    # If a secret key is defined, sign the request with a hash of the key and its content
+    if webhook.secret != '':
+        prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
+
+    # Send the request
+    with requests.Session() as session:
+        session.verify = webhook.ssl_verification
+        if webhook.ca_file_path:
+            session.verify = webhook.ca_file_path
+        response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
+
+    if 200 <= response.status_code <= 299:
+        logger.info(f"Request succeeded; response status {response.status_code}")
+        return f"Status {response.status_code} returned, webhook successfully processed."
+    else:
+        logger.warning(f"Request failed; response status {response.status_code}: {response.content}")
+        raise requests.exceptions.RequestException(
+            f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process."
+        )
diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py
--- a/netbox/extras/webhooks_worker.py
+++ b/netbox/extras/webhooks_worker.py
@@ -1,87 +1,10 @@
-import logging
+import warnings
 
-import requests
-from django.conf import settings
-from django_rq import job
-from jinja2.exceptions import TemplateError
+from .webhooks import send_webhook as process_webhook
 
-from .constants import WEBHOOK_EVENT_TYPES
-from .webhooks import generate_signature
 
-logger = logging.getLogger('netbox.webhooks_worker')
-
-
-@job('default')
-def process_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
-    """
-    Make a POST request to the defined Webhook
-    """
-    webhook = event_rule.action_object
-
-    # Prepare context data for headers & body templates
-    context = {
-        'event': WEBHOOK_EVENT_TYPES[event],
-        'timestamp': timestamp,
-        'model': model_name,
-        'username': username,
-        'request_id': request_id,
-        'data': data,
-    }
-    if snapshots:
-        context.update({
-            'snapshots': snapshots
-        })
-
-    # Build the headers for the HTTP request
-    headers = {
-        'Content-Type': webhook.http_content_type,
-    }
-    try:
-        headers.update(webhook.render_headers(context))
-    except (TemplateError, ValueError) as e:
-        logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}")
-        raise e
-
-    # Render the request body
-    try:
-        body = webhook.render_body(context)
-    except TemplateError as e:
-        logger.error(f"Error rendering request body for webhook {webhook}: {e}")
-        raise e
-
-    # Prepare the HTTP request
-    params = {
-        'method': webhook.http_method,
-        'url': webhook.render_payload_url(context),
-        'headers': headers,
-        'data': body.encode('utf8'),
-    }
-    logger.info(
-        f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})"
-    )
-    logger.debug(params)
-    try:
-        prepared_request = requests.Request(**params).prepare()
-    except requests.exceptions.RequestException as e:
-        logger.error(f"Error forming HTTP request: {e}")
-        raise e
-
-    # If a secret key is defined, sign the request with a hash of the key and its content
-    if webhook.secret != '':
-        prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
-
-    # Send the request
-    with requests.Session() as session:
-        session.verify = webhook.ssl_verification
-        if webhook.ca_file_path:
-            session.verify = webhook.ca_file_path
-        response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
-
-    if 200 <= response.status_code <= 299:
-        logger.info(f"Request succeeded; response status {response.status_code}")
-        return f"Status {response.status_code} returned, webhook successfully processed."
-    else:
-        logger.warning(f"Request failed; response status {response.status_code}: {response.content}")
-        raise requests.exceptions.RequestException(
-            f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process."
-        )
+# TODO: Remove in v4.0
+warnings.warn(
+    f"webhooks_worker.process_webhook has been moved to webhooks.send_webhook.",
+    DeprecationWarning
+)


Test patch:
diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py
--- a/netbox/extras/tests/test_event_rules.py
+++ b/netbox/extras/tests/test_event_rules.py
@@ -11,8 +11,7 @@
 from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
 from extras.events import enqueue_object, flush_events, serialize_for_event
 from extras.models import EventRule, Tag, Webhook
-from extras.webhooks import generate_signature
-from extras.webhooks_worker import process_webhook
+from extras.webhooks import generate_signature, send_webhook
 from requests import Session
 from rest_framework import status
 from utilities.testing import APITestCase
@@ -331,7 +330,7 @@ def test_bulk_delete_process_eventrule(self):
             self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
             self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
 
-    def test_webhooks_worker(self):
+    def test_send_webhook(self):
         request_id = uuid.uuid4()
 
         def dummy_send(_, request, **kwargs):
@@ -376,4 +375,4 @@ def dummy_send(_, request, **kwargs):
 
         # 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.kwargs)
+            send_webhook(**job.kwargs)