Ver Fonte

Closes #16886: Dynamic event types (#16938)

* Initial work on #16886

* Restore GraphQL filter

* Remove namespace

* Add Event documentation

* Use MultipleChoiceField for event_types

* Fix event_types field class on EventRuleImportForm

* Fix tests

* Simplify event queue handling logic

* Misc cleanup
Jeremy Stretch há 1 ano atrás
pai
commit
44a9350986

+ 16 - 0
docs/plugins/development/events.md

@@ -0,0 +1,16 @@
+# Events
+
+Plugins can register their own custom event types for use with NetBox [event rules](../../models/extras/eventrule.md). This is accomplished by calling the `register()` method on an instance of the `Event` class. This can be done anywhere within the plugin. An example is provided below.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.events import Event, EVENT_TYPE_SUCCESS
+
+Event(
+    name='ticket_opened',
+    text=_('Ticket opened'),
+    type=EVENT_TYPE_SUCCESS
+).register()
+```
+
+::: netbox.events.Event

+ 1 - 0
mkdocs.yml

@@ -142,6 +142,7 @@ nav:
             - Forms: 'plugins/development/forms.md'
             - Forms: 'plugins/development/forms.md'
             - Filters & Filter Sets: 'plugins/development/filtersets.md'
             - Filters & Filter Sets: 'plugins/development/filtersets.md'
             - Search: 'plugins/development/search.md'
             - Search: 'plugins/development/search.md'
+            - Events: 'plugins/development/events.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - REST API: 'plugins/development/rest-api.md'
             - REST API: 'plugins/development/rest-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'

+ 8 - 8
netbox/core/events.py

@@ -1,6 +1,6 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from netbox.events import *
+from netbox.events import Event, EVENT_TYPE_DANGER, EVENT_TYPE_SUCCESS, EVENT_TYPE_WARNING
 
 
 __all__ = (
 __all__ = (
     'JOB_COMPLETED',
     'JOB_COMPLETED',
@@ -24,10 +24,10 @@ JOB_FAILED = 'job_failed'
 JOB_ERRORED = 'job_errored'
 JOB_ERRORED = 'job_errored'
 
 
 # Register core events
 # Register core events
-Event(name=OBJECT_CREATED, text=_('Object created')).register()
-Event(name=OBJECT_UPDATED, text=_('Object updated')).register()
-Event(name=OBJECT_DELETED, text=_('Object deleted')).register()
-Event(name=JOB_STARTED, text=_('Job started')).register()
-Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register()
-Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register()
-Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register()
+Event(OBJECT_CREATED, _('Object created')).register()
+Event(OBJECT_UPDATED, _('Object updated')).register()
+Event(OBJECT_DELETED, _('Object deleted')).register()
+Event(JOB_STARTED, _('Job started')).register()
+Event(JOB_COMPLETED, _('Job completed'), type=EVENT_TYPE_SUCCESS).register()
+Event(JOB_FAILED, _('Job failed'), type=EVENT_TYPE_WARNING).register()
+Event(JOB_ERRORED, _('Job errored'), type=EVENT_TYPE_DANGER).register()

+ 3 - 3
netbox/extras/api/serializers_/events.py

@@ -34,9 +34,9 @@ class EventRuleSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
         fields = [
         fields = [
-            'id', 'url', 'display_url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
-            'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
-            'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'object_types', 'name', 'enabled', 'event_types', 'conditions',
+            'action_type', 'action_object_type', 'action_object_id', 'action_object', 'description', 'custom_fields',
+            'tags', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 

+ 7 - 21
netbox/extras/events.py

@@ -1,3 +1,4 @@
+from collections import defaultdict
 import logging
 import logging
 
 
 from django.conf import settings
 from django.conf import settings
@@ -152,35 +153,20 @@ def process_event_queue(events):
     """
     """
     Flush a list of object representation to RQ for EventRule processing.
     Flush a list of object representation to RQ for EventRule processing.
     """
     """
-    events_cache = {
-        'type_create': {},
-        'type_update': {},
-        'type_delete': {},
-    }
-    event_actions = {
-        # TODO: Add EventRule support for dynamically registered event types
-        OBJECT_CREATED: 'type_create',
-        OBJECT_UPDATED: 'type_update',
-        OBJECT_DELETED: 'type_delete',
-        JOB_STARTED: 'type_job_start',
-        JOB_COMPLETED: 'type_job_end',
-        # Map failed & errored jobs to type_job_end
-        JOB_FAILED: 'type_job_end',
-        JOB_ERRORED: 'type_job_end',
-    }
+    events_cache = defaultdict(dict)
 
 
     for event in events:
     for event in events:
-        action_flag = event_actions[event['event_type']]
+        event_type = event['event_type']
         object_type = event['object_type']
         object_type = event['object_type']
 
 
         # Cache applicable Event Rules
         # Cache applicable Event Rules
-        if object_type not in events_cache[action_flag]:
-            events_cache[action_flag][object_type] = EventRule.objects.filter(
-                **{action_flag: True},
+        if object_type not in events_cache[event_type]:
+            events_cache[event_type][object_type] = EventRule.objects.filter(
+                event_types__contains=[event['event_type']],
                 object_types=object_type,
                 object_types=object_type,
                 enabled=True
                 enabled=True
             )
             )
-        event_rules = events_cache[action_flag][object_type]
+        event_rules = events_cache[event_type][object_type]
 
 
         process_event_rules(
         process_event_rules(
             event_rules=event_rules,
             event_rules=event_rules,

+ 7 - 2
netbox/extras/filtersets.py

@@ -99,6 +99,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
     object_type = ContentTypeFilter(
     object_type = ContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
+    event_type = MultiValueCharFilter(
+        method='filter_event_type'
+    )
     action_type = django_filters.MultipleChoiceFilter(
     action_type = django_filters.MultipleChoiceFilter(
         choices=EventRuleActionChoices
         choices=EventRuleActionChoices
     )
     )
@@ -108,8 +111,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
         fields = (
         fields = (
-            'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled',
-            'action_type', 'description',
+            'id', 'name', 'enabled', 'action_type', 'description',
         )
         )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -121,6 +123,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
 
 
+    def filter_event_type(self, queryset, name, value):
+        return queryset.filter(event_types__overlap=value)
+
 
 
 class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
 class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(

+ 9 - 23
netbox/extras/forms/bulk_edit.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
+from netbox.events import get_event_type_choices
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from utilities.forms import BulkEditForm, add_blank_choice
 from utilities.forms import BulkEditForm, add_blank_choice
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
@@ -248,33 +249,18 @@ class EventRuleBulkEditForm(NetBoxModelBulkEditForm):
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
     )
     )
-    type_create = forms.NullBooleanField(
-        label=_('On create'),
+    event_types = forms.MultipleChoiceField(
+        choices=get_event_type_choices(),
         required=False,
         required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    type_update = forms.NullBooleanField(
-        label=_('On update'),
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    type_delete = forms.NullBooleanField(
-        label=_('On delete'),
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    type_job_start = forms.NullBooleanField(
-        label=_('On job start'),
-        required=False,
-        widget=BulkEditNullBooleanSelect()
+        label=_('Event types')
     )
     )
-    type_job_end = forms.NullBooleanField(
-        label=_('On job end'),
-        required=False,
-        widget=BulkEditNullBooleanSelect()
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
     )
     )
 
 
-    nullable_fields = ('description', 'conditions',)
+    nullable_fields = ('description', 'conditions')
 
 
 
 
 class TagBulkEditForm(BulkEditForm):
 class TagBulkEditForm(BulkEditForm):

+ 10 - 4
netbox/extras/forms/bulk_import.py

@@ -8,12 +8,13 @@ from django.utils.translation import gettext_lazy as _
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
+from netbox.events import get_event_type_choices
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from users.models import Group, User
 from users.models import Group, User
 from utilities.forms import CSVModelForm
 from utilities.forms import CSVModelForm
 from utilities.forms.fields import (
 from utilities.forms.fields import (
-    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField,
-    SlugField,
+    CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField,
+    CSVMultipleContentTypeField, SlugField,
 )
 )
 
 
 __all__ = (
 __all__ = (
@@ -187,6 +188,11 @@ class EventRuleImportForm(NetBoxModelImportForm):
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
         help_text=_("One or more assigned object types")
         help_text=_("One or more assigned object types")
     )
     )
+    event_types = CSVMultipleChoiceField(
+        choices=get_event_type_choices(),
+        label=_('Event types'),
+        help_text=_('The event type(s) which will trigger this rule')
+    )
     action_object = forms.CharField(
     action_object = forms.CharField(
         label=_('Action object'),
         label=_('Action object'),
         required=True,
         required=True,
@@ -196,8 +202,8 @@ class EventRuleImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
         fields = (
         fields = (
-            'name', 'description', 'enabled', 'conditions', 'object_types', 'type_create', 'type_update',
-            'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags'
+            'name', 'description', 'enabled', 'conditions', 'object_types', 'event_types', 'action_type',
+            'action_object', 'comments', 'tags'
         )
         )
 
 
     def clean(self):
     def clean(self):

+ 7 - 37
netbox/extras/forms/filtersets.py

@@ -6,6 +6,7 @@ from core.models import ObjectType, DataFile, DataSource
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
+from netbox.events import get_event_type_choices
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.mixins import SavedFiltersMixin
 from netbox.forms.mixins import SavedFiltersMixin
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -274,14 +275,18 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('object_type_id', 'action_type', 'enabled', name=_('Attributes')),
-        FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
+        FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
     )
     )
     object_type_id = ContentTypeMultipleChoiceField(
     object_type_id = ContentTypeMultipleChoiceField(
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
         required=False,
         required=False,
         label=_('Object type')
         label=_('Object type')
     )
     )
+    event_type = forms.MultipleChoiceField(
+        choices=get_event_type_choices,
+        required=False,
+        label=_('Event type')
+    )
     action_type = forms.ChoiceField(
     action_type = forms.ChoiceField(
         choices=add_blank_choice(EventRuleActionChoices),
         choices=add_blank_choice(EventRuleActionChoices),
         required=False,
         required=False,
@@ -294,41 +299,6 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
-    type_create = forms.NullBooleanField(
-        required=False,
-        widget=forms.Select(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        ),
-        label=_('Object creations')
-    )
-    type_update = forms.NullBooleanField(
-        required=False,
-        widget=forms.Select(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        ),
-        label=_('Object updates')
-    )
-    type_delete = forms.NullBooleanField(
-        required=False,
-        widget=forms.Select(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        ),
-        label=_('Object deletions')
-    )
-    type_job_start = forms.NullBooleanField(
-        required=False,
-        widget=forms.Select(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        ),
-        label=_('Job starts')
-    )
-    type_job_end = forms.NullBooleanField(
-        required=False,
-        widget=forms.Select(
-            choices=BOOLEAN_WITH_BLANK_CHOICES
-        ),
-        label=_('Job terminations')
-    )
 
 
 
 
 class TagFilterForm(SavedFiltersMixin, FilterForm):
 class TagFilterForm(SavedFiltersMixin, FilterForm):

+ 8 - 12
netbox/extras/forms/model_forms.py

@@ -10,6 +10,7 @@ from core.models import ObjectType
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
+from netbox.events import get_event_type_choices
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.models import Group, User
 from users.models import Group, User
@@ -303,6 +304,10 @@ class EventRuleForm(NetBoxModelForm):
         label=_('Object types'),
         label=_('Object types'),
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
     )
     )
+    event_types = forms.MultipleChoiceField(
+        choices=get_event_type_choices(),
+        label=_('Event types')
+    )
     action_choice = forms.ChoiceField(
     action_choice = forms.ChoiceField(
         label=_('Action choice'),
         label=_('Action choice'),
         choices=[]
         choices=[]
@@ -319,25 +324,16 @@ class EventRuleForm(NetBoxModelForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
         FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
-        FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
-        FieldSet('conditions', name=_('Conditions')),
+        FieldSet('event_types', 'conditions', name=_('Triggers')),
         FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
         FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
     )
     )
 
 
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
         fields = (
         fields = (
-            'object_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start',
-            'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id',
-            'action_data', 'comments', 'tags'
+            'object_types', 'name', 'description', 'enabled', 'event_types', 'conditions', 'action_type',
+            'action_object_type', 'action_object_id', 'action_data', 'comments', 'tags'
         )
         )
-        labels = {
-            'type_create': _('Creations'),
-            'type_update': _('Updates'),
-            'type_delete': _('Deletions'),
-            'type_job_start': _('Job executions'),
-            'type_job_end': _('Job terminations'),
-        }
         widgets = {
         widgets = {
             'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
             'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
             'action_type': HTMXSelect(),
             'action_type': HTMXSelect(),

+ 75 - 0
netbox/extras/migrations/0119_eventrule_event_types.py

@@ -0,0 +1,75 @@
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+from core.events import *
+
+
+def set_event_types(apps, schema_editor):
+    EventRule = apps.get_model('extras', 'EventRule')
+    event_rules = EventRule.objects.all()
+
+    for event_rule in event_rules:
+        event_rule.event_types = []
+        if event_rule.type_create:
+            event_rule.event_types.append(OBJECT_CREATED)
+        if event_rule.type_update:
+            event_rule.event_types.append(OBJECT_UPDATED)
+        if event_rule.type_delete:
+            event_rule.event_types.append(OBJECT_DELETED)
+        if event_rule.type_job_start:
+            event_rule.event_types.append(JOB_STARTED)
+        if event_rule.type_job_end:
+            # Map type_job_end to all job termination events
+            event_rule.event_types.extend([JOB_COMPLETED, JOB_ERRORED, JOB_FAILED])
+
+    EventRule.objects.bulk_update(event_rules, ['event_types'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0118_notifications'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='eventrule',
+            name='event_types',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.CharField(max_length=50),
+                blank=True,
+                null=True,
+                size=None
+            ),
+        ),
+        migrations.RunPython(
+            code=set_event_types,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.AlterField(
+            model_name='eventrule',
+            name='event_types',
+            field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), size=None),
+            preserve_default=False,
+        ),
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='type_create',
+        ),
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='type_delete',
+        ),
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='type_job_end',
+        ),
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='type_job_start',
+        ),
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='type_update',
+        ),
+    ]

+ 5 - 32
netbox/extras/models/models.py

@@ -3,6 +3,7 @@ import urllib.parse
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.postgres.fields import ArrayField
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
 from django.http import HttpResponse
 from django.http import HttpResponse
@@ -17,6 +18,7 @@ from extras.conditions import ConditionSet
 from extras.constants import *
 from extras.constants import *
 from extras.utils import image_upload
 from extras.utils import image_upload
 from netbox.config import get_config
 from netbox.config import get_config
+from netbox.events import get_event_type_choices
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import (
 from netbox.models.features import (
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
     CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
@@ -60,30 +62,9 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
         max_length=200,
         max_length=200,
         blank=True
         blank=True
     )
     )
-    type_create = models.BooleanField(
-        verbose_name=_('on create'),
-        default=False,
-        help_text=_("Triggers when a matching object is created.")
-    )
-    type_update = models.BooleanField(
-        verbose_name=_('on update'),
-        default=False,
-        help_text=_("Triggers when a matching object is updated.")
-    )
-    type_delete = models.BooleanField(
-        verbose_name=_('on delete'),
-        default=False,
-        help_text=_("Triggers when a matching object is deleted.")
-    )
-    type_job_start = models.BooleanField(
-        verbose_name=_('on job start'),
-        default=False,
-        help_text=_("Triggers when a job for a matching object is started.")
-    )
-    type_job_end = models.BooleanField(
-        verbose_name=_('on job end'),
-        default=False,
-        help_text=_("Triggers when a job for a matching object terminates.")
+    event_types = ArrayField(
+        base_field=models.CharField(max_length=50, choices=get_event_type_choices),
+        help_text=_("The types of event which will trigger this rule.")
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
         verbose_name=_('enabled'),
         verbose_name=_('enabled'),
@@ -144,14 +125,6 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        # At least one action type must be selected
-        if not any([
-            self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end
-        ]):
-            raise ValidationError(
-                _("At least one event type must be selected: create, update, delete, job start, and/or job end.")
-            )
-
         # Validate that any conditions are in the correct format
         # Validate that any conditions are in the correct format
         if self.conditions:
         if self.conditions:
             try:
             try:

+ 7 - 18
netbox/extras/tables/tables.py

@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from extras.models import *
 from extras.models import *
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.constants import EMPTY_TABLE_TEXT
+from netbox.events import get_event_text
 from netbox.tables import BaseTable, NetBoxTable, columns
 from netbox.tables import BaseTable, NetBoxTable, columns
 from .columns import NotificationActionsColumn
 from .columns import NotificationActionsColumn
 
 
@@ -399,20 +400,10 @@ class EventRuleTable(NetBoxTable):
     enabled = columns.BooleanColumn(
     enabled = columns.BooleanColumn(
         verbose_name=_('Enabled'),
         verbose_name=_('Enabled'),
     )
     )
-    type_create = columns.BooleanColumn(
-        verbose_name=_('Create')
-    )
-    type_update = columns.BooleanColumn(
-        verbose_name=_('Update')
-    )
-    type_delete = columns.BooleanColumn(
-        verbose_name=_('Delete')
-    )
-    type_job_start = columns.BooleanColumn(
-        verbose_name=_('Job Start')
-    )
-    type_job_end = columns.BooleanColumn(
-        verbose_name=_('Job End')
+    event_types = columns.ArrayColumn(
+        verbose_name=_('Event Types'),
+        func=get_event_text,
+        orderable=False
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='extras:webhook_list'
         url_name='extras:webhook_list'
@@ -422,12 +413,10 @@ class EventRuleTable(NetBoxTable):
         model = EventRule
         model = EventRule
         fields = (
         fields = (
             'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'object_types',
             'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'object_types',
-            'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created',
-            'last_updated',
+            'event_types', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update',
-            'type_delete', 'type_job_start', 'type_job_end',
+            'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'event_types',
         )
         )
 
 
 
 

+ 7 - 7
netbox/extras/tests/test_api.py

@@ -1,6 +1,5 @@
 import datetime
 import datetime
 
 
-from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware
 from django.utils.timezone import make_aware
@@ -13,6 +12,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
+from netbox.events import *
 from users.models import Group, User
 from users.models import Group, User
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
@@ -113,9 +113,9 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
         Webhook.objects.bulk_create(webhooks)
         Webhook.objects.bulk_create(webhooks)
 
 
         event_rules = (
         event_rules = (
-            EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
-            EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
-            EventRule(name='EventRule 3', type_create=True, action_object=webhooks[2]),
+            EventRule(name='EventRule 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
+            EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
+            EventRule(name='EventRule 3', event_types=[OBJECT_CREATED], action_object=webhooks[2]),
         )
         )
         EventRule.objects.bulk_create(event_rules)
         EventRule.objects.bulk_create(event_rules)
 
 
@@ -123,7 +123,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'EventRule 4',
                 'name': 'EventRule 4',
                 'object_types': ['dcim.device', 'dcim.devicetype'],
                 'object_types': ['dcim.device', 'dcim.devicetype'],
-                'type_create': True,
+                'event_types': [OBJECT_CREATED],
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
                 'action_object_type': 'extras.webhook',
                 'action_object_id': webhooks[3].pk,
                 'action_object_id': webhooks[3].pk,
@@ -131,7 +131,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'EventRule 5',
                 'name': 'EventRule 5',
                 'object_types': ['dcim.device', 'dcim.devicetype'],
                 'object_types': ['dcim.device', 'dcim.devicetype'],
-                'type_create': True,
+                'event_types': [OBJECT_CREATED],
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
                 'action_object_type': 'extras.webhook',
                 'action_object_id': webhooks[4].pk,
                 'action_object_id': webhooks[4].pk,
@@ -139,7 +139,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'EventRule 6',
                 'name': 'EventRule 6',
                 'object_types': ['dcim.device', 'dcim.devicetype'],
                 'object_types': ['dcim.device', 'dcim.devicetype'],
-                'type_create': True,
+                'event_types': [OBJECT_CREATED],
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_type': EventRuleActionChoices.WEBHOOK,
                 'action_object_type': 'extras.webhook',
                 'action_object_type': 'extras.webhook',
                 'action_object_id': webhooks[5].pk,
                 'action_object_id': webhooks[5].pk,

+ 5 - 8
netbox/extras/tests/test_conditions.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 
 
+from core.events import *
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.conditions import Condition, ConditionSet
 from extras.conditions import Condition, ConditionSet
@@ -230,8 +231,7 @@ class ConditionSetTest(TestCase):
         """
         """
         event_rule = EventRule(
         event_rule = EventRule(
             name='Event Rule 1',
             name='Event Rule 1',
-            type_create=True,
-            type_update=True,
+            event_types=[OBJECT_CREATED, OBJECT_UPDATED],
             conditions={
             conditions={
                 'attr': 'status.value',
                 'attr': 'status.value',
                 'value': 'active',
                 'value': 'active',
@@ -251,8 +251,7 @@ class ConditionSetTest(TestCase):
         """
         """
         event_rule = EventRule(
         event_rule = EventRule(
             name='Event Rule 1',
             name='Event Rule 1',
-            type_create=True,
-            type_update=True,
+            event_types=[OBJECT_CREATED, OBJECT_UPDATED],
             conditions={
             conditions={
                 "attr": "status.value",
                 "attr": "status.value",
                 "value": ["planned", "staging"],
                 "value": ["planned", "staging"],
@@ -273,8 +272,7 @@ class ConditionSetTest(TestCase):
         """
         """
         event_rule = EventRule(
         event_rule = EventRule(
             name='Event Rule 1',
             name='Event Rule 1',
-            type_create=True,
-            type_update=True,
+            event_types=[OBJECT_CREATED, OBJECT_UPDATED],
             conditions={
             conditions={
                 "attr": "status.value",
                 "attr": "status.value",
                 "value": ["planned", "staging"],
                 "value": ["planned", "staging"],
@@ -300,8 +298,7 @@ class ConditionSetTest(TestCase):
         webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
         webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
         form = EventRuleForm({
         form = EventRuleForm({
             "name": "Event Rule 1",
             "name": "Event Rule 1",
-            "type_create": True,
-            "type_update": True,
+            "event_types": [OBJECT_CREATED, OBJECT_UPDATED],
             "action_object_type": ct.pk,
             "action_object_type": ct.pk,
             "action_type": "webhook",
             "action_type": "webhook",
             "action_choice": webhook.pk,
             "action_choice": webhook.pk,

+ 14 - 15
netbox/extras/tests/test_event_rules.py

@@ -46,22 +46,22 @@ class EventRuleTest(APITestCase):
         webhook_type = ObjectType.objects.get(app_label='extras', model='webhook')
         webhook_type = ObjectType.objects.get(app_label='extras', model='webhook')
         event_rules = EventRule.objects.bulk_create((
         event_rules = EventRule.objects.bulk_create((
             EventRule(
             EventRule(
-                name='Webhook Event 1',
-                type_create=True,
+                name='Event Rule 1',
+                event_types=[OBJECT_CREATED],
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_object_type=webhook_type,
                 action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
                 action_object_id=webhooks[0].id
             ),
             ),
             EventRule(
             EventRule(
-                name='Webhook Event 2',
-                type_update=True,
+                name='Event Rule 2',
+                event_types=[OBJECT_UPDATED],
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_object_type=webhook_type,
                 action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
                 action_object_id=webhooks[0].id
             ),
             ),
             EventRule(
             EventRule(
-                name='Webhook Event 3',
-                type_delete=True,
+                name='Event Rule 3',
+                event_types=[OBJECT_DELETED],
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_object_type=webhook_type,
                 action_object_type=webhook_type,
                 action_object_id=webhooks[0].id
                 action_object_id=webhooks[0].id
@@ -82,8 +82,7 @@ class EventRuleTest(APITestCase):
         """
         """
         event_rule = EventRule(
         event_rule = EventRule(
             name='Event Rule 1',
             name='Event Rule 1',
-            type_create=True,
-            type_update=True,
+            event_types=[OBJECT_CREATED, OBJECT_UPDATED],
             conditions={
             conditions={
                 'and': [
                 'and': [
                     {
                     {
@@ -131,7 +130,7 @@ class EventRuleTest(APITestCase):
         # Verify that a background task was queued for the new object
         # Verify that a background task was queued for the new object
         self.assertEqual(self.queue.count, 1)
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
-        self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
+        self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1'))
         self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
         self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['data']['id'], response.data['id'])
         self.assertEqual(job.kwargs['data']['id'], response.data['id'])
@@ -181,7 +180,7 @@ class EventRuleTest(APITestCase):
         # Verify that a background task was queued for each new object
         # Verify that a background task was queued for each new object
         self.assertEqual(self.queue.count, 3)
         self.assertEqual(self.queue.count, 3)
         for i, job in enumerate(self.queue.jobs):
         for i, job in enumerate(self.queue.jobs):
-            self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
+            self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1'))
             self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
             self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
             self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
@@ -212,7 +211,7 @@ class EventRuleTest(APITestCase):
         # Verify that a background task was queued for the updated object
         # Verify that a background task was queued for the updated object
         self.assertEqual(self.queue.count, 1)
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
-        self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
+        self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2'))
         self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
         self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['data']['id'], site.pk)
         self.assertEqual(job.kwargs['data']['id'], site.pk)
@@ -268,7 +267,7 @@ class EventRuleTest(APITestCase):
         # Verify that a background task was queued for each updated object
         # Verify that a background task was queued for each updated object
         self.assertEqual(self.queue.count, 3)
         self.assertEqual(self.queue.count, 3)
         for i, job in enumerate(self.queue.jobs):
         for i, job in enumerate(self.queue.jobs):
-            self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
+            self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2'))
             self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
             self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
             self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
@@ -294,7 +293,7 @@ class EventRuleTest(APITestCase):
         # Verify that a task was queued for the deleted object
         # Verify that a task was queued for the deleted object
         self.assertEqual(self.queue.count, 1)
         self.assertEqual(self.queue.count, 1)
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
-        self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
+        self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3'))
         self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
         self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['model_name'], 'site')
         self.assertEqual(job.kwargs['data']['id'], site.pk)
         self.assertEqual(job.kwargs['data']['id'], site.pk)
@@ -327,7 +326,7 @@ class EventRuleTest(APITestCase):
         # Verify that a background task was queued for each deleted object
         # Verify that a background task was queued for each deleted object
         self.assertEqual(self.queue.count, 3)
         self.assertEqual(self.queue.count, 3)
         for i, job in enumerate(self.queue.jobs):
         for i, job in enumerate(self.queue.jobs):
-            self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
+            self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3'))
             self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
             self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['model_name'], 'site')
             self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
             self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
@@ -342,7 +341,7 @@ class EventRuleTest(APITestCase):
             A dummy implementation of Session.send() to be used for testing.
             A dummy implementation of Session.send() to be used for testing.
             Always returns a 200 HTTP response.
             Always returns a 200 HTTP response.
             """
             """
-            event = EventRule.objects.get(type_create=True)
+            event = EventRule.objects.get(name='Event Rule 1')
             webhook = event.action_object
             webhook = event.action_object
             signature = generate_signature(request.body, webhook.secret)
             signature = generate_signature(request.body, webhook.secret)
 
 

+ 10 - 45
netbox/extras/tests/test_filtersets.py

@@ -6,6 +6,7 @@ from django.test import TestCase
 
 
 from circuits.models import Provider
 from circuits.models import Provider
 from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
 from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
+from core.events import *
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from dcim.filtersets import SiteFilterSet
 from dcim.filtersets import SiteFilterSet
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
@@ -251,7 +252,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
 class EventRuleTestCase(TestCase, BaseFilterSetTests):
 class EventRuleTestCase(TestCase, BaseFilterSetTests):
     queryset = EventRule.objects.all()
     queryset = EventRule.objects.all()
     filterset = EventRuleFilterSet
     filterset = EventRuleFilterSet
-    ignore_fields = ('action_data', 'conditions')
+    ignore_fields = ('action_data', 'conditions', 'event_types')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -292,11 +293,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
                 name='Event Rule 1',
                 name='Event Rule 1',
                 action_object=webhooks[0],
                 action_object=webhooks[0],
                 enabled=True,
                 enabled=True,
-                type_create=True,
-                type_update=False,
-                type_delete=False,
-                type_job_start=False,
-                type_job_end=False,
+                event_types=[OBJECT_CREATED],
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 description='foobar1'
                 description='foobar1'
             ),
             ),
@@ -304,11 +301,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
                 name='Event Rule 2',
                 name='Event Rule 2',
                 action_object=webhooks[1],
                 action_object=webhooks[1],
                 enabled=True,
                 enabled=True,
-                type_create=False,
-                type_update=True,
-                type_delete=False,
-                type_job_start=False,
-                type_job_end=False,
+                event_types=[OBJECT_UPDATED],
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 description='foobar2'
                 description='foobar2'
             ),
             ),
@@ -316,11 +309,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
                 name='Event Rule 3',
                 name='Event Rule 3',
                 action_object=webhooks[2],
                 action_object=webhooks[2],
                 enabled=False,
                 enabled=False,
-                type_create=False,
-                type_update=False,
-                type_delete=True,
-                type_job_start=False,
-                type_job_end=False,
+                event_types=[OBJECT_DELETED],
                 action_type=EventRuleActionChoices.WEBHOOK,
                 action_type=EventRuleActionChoices.WEBHOOK,
                 description='foobar3'
                 description='foobar3'
             ),
             ),
@@ -328,22 +317,14 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
                 name='Event Rule 4',
                 name='Event Rule 4',
                 action_object=scripts[0],
                 action_object=scripts[0],
                 enabled=False,
                 enabled=False,
-                type_create=False,
-                type_update=False,
-                type_delete=False,
-                type_job_start=True,
-                type_job_end=False,
+                event_types=[JOB_STARTED],
                 action_type=EventRuleActionChoices.SCRIPT,
                 action_type=EventRuleActionChoices.SCRIPT,
             ),
             ),
             EventRule(
             EventRule(
                 name='Event Rule 5',
                 name='Event Rule 5',
                 action_object=scripts[1],
                 action_object=scripts[1],
                 enabled=False,
                 enabled=False,
-                type_create=False,
-                type_update=False,
-                type_delete=False,
-                type_job_start=False,
-                type_job_end=True,
+                event_types=[JOB_COMPLETED],
                 action_type=EventRuleActionChoices.SCRIPT,
                 action_type=EventRuleActionChoices.SCRIPT,
             ),
             ),
         )
         )
@@ -384,25 +365,9 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         params = {'enabled': False}
         params = {'enabled': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
-    def test_type_create(self):
-        params = {'type_create': True}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
-    def test_type_update(self):
-        params = {'type_update': True}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
-    def test_type_delete(self):
-        params = {'type_delete': True}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
-    def test_type_job_start(self):
-        params = {'type_job_start': True}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
-    def test_type_job_end(self):
-        params = {'type_job_end': True}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+    def test_event_type(self):
+        params = {'event_type': [OBJECT_CREATED, OBJECT_UPDATED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
 class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
 class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):

+ 8 - 9
netbox/extras/tests/test_views.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
+from core.events import *
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.models import DeviceType, Manufacturer, Site
 from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.choices import *
@@ -394,9 +395,9 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         site_type = ObjectType.objects.get_for_model(Site)
         site_type = ObjectType.objects.get_for_model(Site)
         event_rules = (
         event_rules = (
-            EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
-            EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
-            EventRule(name='EventRule 3', type_create=True, action_object=webhooks[2]),
+            EventRule(name='EventRule 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
+            EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
+            EventRule(name='EventRule 3', event_types=[OBJECT_CREATED], action_object=webhooks[2]),
         )
         )
         for event in event_rules:
         for event in event_rules:
             event.save()
             event.save()
@@ -406,9 +407,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         cls.form_data = {
         cls.form_data = {
             'name': 'Event X',
             'name': 'Event X',
             'object_types': [site_type.pk],
             'object_types': [site_type.pk],
-            'type_create': False,
-            'type_update': True,
-            'type_delete': True,
+            'event_types': [OBJECT_UPDATED, OBJECT_DELETED],
             'conditions': None,
             'conditions': None,
             'action_type': 'webhook',
             'action_type': 'webhook',
             'action_object_type': webhook_ct.pk,
             'action_object_type': webhook_ct.pk,
@@ -418,8 +417,8 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "name,object_types,type_create,action_type,action_object",
-            "Webhook 4,dcim.site,True,webhook,Webhook 1",
+            f'name,object_types,event_types,action_type,action_object',
+            f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1',
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
@@ -430,7 +429,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
-            'type_update': True,
+            'description': 'New description',
         }
         }
 
 
 
 

+ 9 - 0
netbox/extras/views.py

@@ -19,6 +19,7 @@ from extras.choices import LogLevelChoices
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
+from netbox.registry import registry
 from netbox.views import generic
 from netbox.views import generic
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.forms import ConfirmationForm, get_field_value
@@ -550,6 +551,14 @@ class EventRuleListView(generic.ObjectListView):
 class EventRuleView(generic.ObjectView):
 class EventRuleView(generic.ObjectView):
     queryset = EventRule.objects.all()
     queryset = EventRule.objects.all()
 
 
+    def get_extra_context(self, request, instance):
+        return {
+            'event_types': [
+                event for name, event in registry['events'].items()
+                if name in instance.event_types
+            ]
+        }
+
 
 
 @register_model_view(EventRule, 'edit')
 @register_model_view(EventRule, 'edit')
 class EventRuleEditView(generic.ObjectEditView):
 class EventRuleEditView(generic.ObjectEditView):

+ 30 - 0
netbox/netbox/events.py

@@ -13,11 +13,39 @@ __all__ = (
     'EVENT_TYPE_SUCCESS',
     'EVENT_TYPE_SUCCESS',
     'EVENT_TYPE_WARNING',
     'EVENT_TYPE_WARNING',
     'Event',
     'Event',
+    'get_event',
+    'get_event_type_choices',
+    'get_event_text',
 )
 )
 
 
 
 
+def get_event(name):
+    return registry['events'].get(name)
+
+
+def get_event_text(name):
+    if event := registry['events'].get(name):
+        return event.text
+    return ''
+
+
+def get_event_type_choices():
+    return [
+        (event.name, event.text) for event in registry['events'].values()
+    ]
+
+
 @dataclass
 @dataclass
 class Event:
 class Event:
+    """
+    A type of event which can occur in NetBox. Event rules can be defined to automatically
+    perform some action in response to an event.
+
+    Args:
+        name: The unique name under which the event is registered.
+        text: The human-friendly event name. This should support translation.
+        type: The event's classification (info, success, warning, or danger). The default type is info.
+    """
     name: str
     name: str
     text: str
     text: str
     type: str = EVENT_TYPE_INFO
     type: str = EVENT_TYPE_INFO
@@ -26,6 +54,8 @@ class Event:
         return self.text
         return self.text
 
 
     def register(self):
     def register(self):
+        if self.name in registry['events']:
+            raise Exception(f"An event named {self.name} has already been registered!")
         registry['events'][self.name] = self
         registry['events'][self.name] = self
 
 
     def color(self):
     def color(self):

+ 19 - 23
netbox/templates/extras/eventrule.html

@@ -34,29 +34,25 @@
         </table>
         </table>
       </div>
       </div>
       <div class="card">
       <div class="card">
-        <h5 class="card-header">{% trans "Events" %}</h5>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Create" %}</th>
-            <td>{% checkmark object.type_create %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Update" %}</th>
-            <td>{% checkmark object.type_update %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Delete" %}</th>
-            <td>{% checkmark object.type_delete %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Job start" %}</th>
-            <td>{% checkmark object.type_job_start %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Job end" %}</th>
-            <td>{% checkmark object.type_job_end %}</td>
-          </tr>
-        </table>
+        <h5 class="card-header">{% trans "Event Types" %}</h5>
+        <ul class="list-group list-group-flush">
+          {% for name, event in registry.events.items %}
+            <li class="list-group-item">
+              <div class="row align-items-center">
+                <div class="col-auto">
+                  {% if name in object.event_types %}
+                    {% checkmark True %}
+                  {% else %}
+                    {{ ''|placeholder }}
+                  {% endif %}
+                </div>
+                <div class="col">
+                  {{ event }}
+                </div>
+              </div>
+            </li>
+          {% endfor %}
+        </ul>
       </div>
       </div>
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>

+ 4 - 1
netbox/utilities/testing/base.py

@@ -137,7 +137,10 @@ class ModelTestCase(TestCase):
 
 
                 # Convert ArrayFields to CSV strings
                 # Convert ArrayFields to CSV strings
                 if type(field) is ArrayField:
                 if type(field) is ArrayField:
-                    if type(field.base_field) is ArrayField:
+                    if getattr(field.base_field, 'choices', None):
+                        # Values for fields with pre-defined choices can be returned as lists
+                        model_dict[key] = value
+                    elif type(field.base_field) is ArrayField:
                         # Handle nested arrays (e.g. choice sets)
                         # Handle nested arrays (e.g. choice sets)
                         model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
                         model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
                     elif issubclass(type(field.base_field), RangeField):
                     elif issubclass(type(field.base_field), RangeField):