Repository: netbox-community/netbox

Issue:
Introduce signals indicating the execution or termination of a job
### NetBox version

v3.6.5

### Feature type

New functionality

### Proposed functionality

Introduce two new signals, `job_start` and `job_end`, which fire when a job is executed or terminated, respectively. This will allow us to move the `trigger_webhooks()` method from the Job model into a standalone receiver.

### Use case

Exposing these signals standardizes event behavior in #14132 and enables plugins to interact with job events.

### Database changes

N/A

### External dependencies

N/A


Discussion:

Resolving patch:
diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py
--- a/netbox/core/models/jobs.py
+++ b/netbox/core/models/jobs.py
@@ -12,6 +12,7 @@
 
 from core.choices import JobStatusChoices
 from core.models import ContentType
+from core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from netbox.config import get_config
 from netbox.constants import RQ_QUEUE_DEFAULT
@@ -168,8 +169,8 @@ def start(self):
         self.status = JobStatusChoices.STATUS_RUNNING
         self.save()
 
-        # Handle events
-        self.process_event(event=EVENT_JOB_START)
+        # Send signal
+        job_start.send(self)
 
     def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
         """
@@ -186,8 +187,8 @@ def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
         self.completed = timezone.now()
         self.save()
 
-        # Handle events
-        self.process_event(event=EVENT_JOB_END)
+        # Send signal
+        job_end.send(self)
 
     @classmethod
     def enqueue(cls, func, instance, name='', user=None, schedule_at=None, interval=None, **kwargs):
@@ -223,19 +224,3 @@ def enqueue(cls, func, instance, name='', user=None, schedule_at=None, interval=
             queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
 
         return job
-
-    def process_event(self, event):
-        """
-        Process any EventRules relevant to the passed job event (i.e. start or stop).
-        """
-        from extras.models import EventRule
-        from extras.events import process_event_rules
-
-        # Fetch any event rules matching this object type and action
-        event_rules = EventRule.objects.filter(
-            **{f'type_{event}': True},
-            content_types=self.object_type,
-            enabled=True
-        )
-
-        process_event_rules(event_rules, self.object_type.model, event, self.data, self.user.username)
diff --git a/netbox/core/signals.py b/netbox/core/signals.py
--- a/netbox/core/signals.py
+++ b/netbox/core/signals.py
@@ -4,10 +4,16 @@
 from .models import ConfigRevision
 
 __all__ = (
+    'job_end',
+    'job_start',
     'post_sync',
     'pre_sync',
 )
 
+# Job signals
+job_start = Signal()
+job_end = Signal()
+
 # DataSource signals
 pre_sync = Signal()
 post_sync = Signal()
diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -8,6 +8,10 @@
 from django.utils.translation import gettext_lazy as _
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
+from core.signals import job_end, job_start
+from extras.constants import EVENT_JOB_END, EVENT_JOB_START
+from extras.events import process_event_rules
+from extras.models import EventRule
 from extras.validators import CustomValidator
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
@@ -235,3 +239,25 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
     for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
         if ct not in tag.object_types.all():
             raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
+
+
+#
+# Event rules
+#
+
+@receiver(job_start)
+def process_job_start_event_rules(sender, **kwargs):
+    """
+    Process event rules for jobs starting.
+    """
+    event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type)
+    process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, sender.user.username)
+
+
+@receiver(job_end)
+def process_job_end_event_rules(sender, **kwargs):
+    """
+    Process event rules for jobs terminating.
+    """
+    event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type)
+    process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, sender.user.username)


Test patch: