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

fix(extras): Handle username fallback for job events

Fallback to the associated user when username is missing from job
lifecycle event contexts. Add a regression test to ensure JOB_COMPLETED
webhooks are enqueued without a request context.

Fixes #21371
Martin Hauser 1 день назад
Родитель
Сommit
bdd23f3d17
2 измененных файлов с 64 добавлено и 15 удалено
  1. 31 13
      netbox/extras/events.py
  2. 33 2
      netbox/extras/tests/test_event_rules.py

+ 31 - 13
netbox/extras/events.py

@@ -113,6 +113,17 @@ def enqueue_event(queue, instance, request, event_type):
 def process_event_rules(event_rules, object_type, event):
     """
     Process a list of EventRules against an event.
+
+    Notes on event sources:
+    - Object change events (created/updated/deleted) are enqueued via
+      enqueue_event() during an HTTP request.
+      These events include a request object and legacy request
+      attributes (e.g. username, request_id) for backward compatibility.
+    - Job lifecycle events (JOB_STARTED/JOB_COMPLETED) are emitted by
+      job_start/job_end signal handlers and may not include a request
+      context.
+      Consumers must not assume that fields like `username` are always
+      present.
     """
 
     for event_rule in event_rules:
@@ -132,16 +143,22 @@ def process_event_rules(event_rules, object_type, event):
             queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
             rq_queue = get_queue(queue_name)
 
+            # For job lifecycle events, `username` may be absent because
+            # there is no request context.
+            # Prefer the associated user object when present, falling
+            # back to the legacy username attribute.
+            username = getattr(event.get('user'), 'username', None) or event.get('username')
+
             # Compile the task parameters
             params = {
-                "event_rule": event_rule,
-                "object_type": object_type,
-                "event_type": event['event_type'],
-                "data": event_data,
-                "snapshots": event.get('snapshots'),
-                "timestamp": timezone.now().isoformat(),
-                "username": event['username'],
-                "retry": get_rq_retry()
+                'event_rule': event_rule,
+                'object_type': object_type,
+                'event_type': event['event_type'],
+                'data': event_data,
+                'snapshots': event.get('snapshots'),
+                'timestamp': timezone.now().isoformat(),
+                'username': username,
+                'retry': get_rq_retry(),
             }
             if 'request' in event:
                 # Exclude FILES - webhooks don't need uploaded files,
@@ -158,11 +175,12 @@ def process_event_rules(event_rules, object_type, event):
 
             # Enqueue a Job to record the script's execution
             from extras.jobs import ScriptJob
+
             params = {
-                "instance": event_rule.action_object,
-                "name": script.name,
-                "user": event['user'],
-                "data": event_data
+                'instance': event_rule.action_object,
+                'name': script.name,
+                'user': event['user'],
+                'data': event_data,
             }
             if 'snapshots' in event:
                 params['snapshots'] = event['snapshots']
@@ -179,7 +197,7 @@ def process_event_rules(event_rules, object_type, event):
                 object_type=object_type,
                 object_id=event_data['id'],
                 object_repr=event_data.get('display'),
-                event_type=event['event_type']
+                event_type=event['event_type'],
             )
 
         else:

+ 33 - 2
netbox/extras/tests/test_event_rules.py

@@ -1,6 +1,6 @@
 import json
 import uuid
-from unittest.mock import patch
+from unittest.mock import Mock, patch
 
 import django_rq
 from django.http import HttpResponse
@@ -15,7 +15,8 @@ from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from extras.choices import EventRuleActionChoices
 from extras.events import enqueue_event, flush_events, serialize_for_event
-from extras.models import EventRule, Tag, Webhook
+from extras.models import EventRule, Script, Tag, Webhook
+from extras.signals import process_job_end_event_rules
 from extras.webhooks import generate_signature, send_webhook
 from netbox.context_managers import event_tracking
 from utilities.testing import APITestCase
@@ -395,6 +396,36 @@ class EventRuleTest(APITestCase):
         with patch.object(Session, 'send', dummy_send):
             send_webhook(**job.kwargs)
 
+    def test_job_completed_webhook_username_fallback(self):
+        """
+        Ensure job_end event processing can enqueue a webhook even when the EventContext
+        lacks legacy request attributes (e.g. `username`).
+
+        The job_start/job_end signal receivers only populate `user` and `data`, so webhook
+        processing must derive the username from the user object (or tolerate it being unset).
+        """
+        script_type = ObjectType.objects.get_for_model(Script)
+        webhook_type = ObjectType.objects.get_for_model(Webhook)
+        webhook = Webhook.objects.get(name='Webhook 1')
+        event_rule = EventRule.objects.create(
+            name='Event Rule Job Completed',
+            event_types=[JOB_COMPLETED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=webhook_type,
+            action_object_id=webhook.pk,
+        )
+        event_rule.object_types.set([script_type])
+        # Mimic the `core.job_end` signal sender expected by extras.signals.process_job_end_event_rules
+        # (notably: no request, and thus no legacy `username`)
+        sender = Mock(object_type=script_type, data={}, user=self.user)
+        process_job_end_event_rules(sender)
+        self.assertEqual(self.queue.count, 1)
+        job = self.queue.jobs[0]
+        self.assertEqual(job.kwargs['event_rule'], event_rule)
+        self.assertEqual(job.kwargs['event_type'], JOB_COMPLETED)
+        self.assertEqual(job.kwargs['object_type'], script_type)
+        self.assertEqual(job.kwargs['username'], self.user.username)
+
     def test_duplicate_triggers(self):
         """
         Test for erroneous duplicate event triggers resulting from saving an object multiple times