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
This commit is contained in:
Jeremy Stretch 2024-07-22 08:12:04 -04:00 committed by GitHub
parent 4a53a96541
commit 44a9350986
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 269 additions and 268 deletions

View File

@ -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

View File

@ -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'

View File

@ -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(OBJECT_CREATED, _('Object created')).register()
Event(name=OBJECT_UPDATED, text=_('Object updated')).register() Event(OBJECT_UPDATED, _('Object updated')).register()
Event(name=OBJECT_DELETED, text=_('Object deleted')).register() Event(OBJECT_DELETED, _('Object deleted')).register()
Event(name=JOB_STARTED, text=_('Job started')).register() Event(JOB_STARTED, _('Job started')).register()
Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register() Event(JOB_COMPLETED, _('Job completed'), type=EVENT_TYPE_SUCCESS).register()
Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register() Event(JOB_FAILED, _('Job failed'), type=EVENT_TYPE_WARNING).register()
Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register() Event(JOB_ERRORED, _('Job errored'), type=EVENT_TYPE_DANGER).register()

View File

@ -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', 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'enabled', 'event_types', 'conditions',
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_type', 'action_object_type', 'action_object_id', 'action_object', 'description', 'custom_fields',
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated', 'tags', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -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 = { events_cache = defaultdict(dict)
'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',
}
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]: if object_type not in events_cache[event_type]:
events_cache[action_flag][object_type] = EventRule.objects.filter( events_cache[event_type][object_type] = EventRule.objects.filter(
**{action_flag: True}, 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,

View File

@ -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', 'id', 'name', 'enabled', 'action_type', 'description',
'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(

View File

@ -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( event_types = forms.MultipleChoiceField(
label=_('On create'), choices=get_event_type_choices(),
required=False, required=False,
widget=BulkEditNullBooleanSelect() label=_('Event types')
) )
type_update = forms.NullBooleanField( description = forms.CharField(
label=_('On update'), label=_('Description'),
required=False, max_length=200,
widget=BulkEditNullBooleanSelect() required=False
)
type_delete = forms.NullBooleanField(
label=_('On delete'),
required=False,
widget=BulkEditNullBooleanSelect()
)
type_job_start = forms.NullBooleanField(
label=_('On job start'),
required=False,
widget=BulkEditNullBooleanSelect()
)
type_job_end = forms.NullBooleanField(
label=_('On job end'),
required=False,
widget=BulkEditNullBooleanSelect()
) )
nullable_fields = ('description', 'conditions',) nullable_fields = ('description', 'conditions')
class TagBulkEditForm(BulkEditForm): class TagBulkEditForm(BulkEditForm):

View File

@ -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, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField,
SlugField, 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', 'name', 'description', 'enabled', 'conditions', 'object_types', 'event_types', 'action_type',
'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags' 'action_object', 'comments', 'tags'
) )
def clean(self): def clean(self):

View File

@ -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('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
) )
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):

View File

@ -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('event_types', 'conditions', name=_('Triggers')),
FieldSet('conditions', name=_('Conditions')),
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', 'object_types', 'name', 'description', 'enabled', 'event_types', 'conditions', 'action_type',
'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id', 'action_object_type', 'action_object_id', 'action_data', 'comments', 'tags'
'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(),

View File

@ -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',
),
]

View File

@ -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( event_types = ArrayField(
verbose_name=_('on create'), base_field=models.CharField(max_length=50, choices=get_event_type_choices),
default=False, help_text=_("The types of event which will trigger this rule.")
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.")
) )
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:

View File

@ -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( event_types = columns.ArrayColumn(
verbose_name=_('Create') verbose_name=_('Event Types'),
) func=get_event_text,
type_update = columns.BooleanColumn( orderable=False
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')
) )
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', 'event_types', 'tags', 'created', 'last_updated',
'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update', 'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'event_types',
'type_delete', 'type_job_start', 'type_job_end',
) )

View File

@ -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 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]), EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
EventRule(name='EventRule 3', type_create=True, action_object=webhooks[2]), 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,

View File

@ -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, event_types=[OBJECT_CREATED, OBJECT_UPDATED],
type_update=True,
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, event_types=[OBJECT_CREATED, OBJECT_UPDATED],
type_update=True,
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, event_types=[OBJECT_CREATED, OBJECT_UPDATED],
type_update=True,
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, "event_types": [OBJECT_CREATED, OBJECT_UPDATED],
"type_update": True,
"action_object_type": ct.pk, "action_object_type": ct.pk,
"action_type": "webhook", "action_type": "webhook",
"action_choice": webhook.pk, "action_choice": webhook.pk,

View File

@ -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', name='Event Rule 1',
type_create=True, 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', name='Event Rule 2',
type_update=True, 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', name='Event Rule 3',
type_delete=True, 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, event_types=[OBJECT_CREATED, OBJECT_UPDATED],
type_update=True,
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)

View File

@ -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, event_types=[OBJECT_CREATED],
type_update=False,
type_delete=False,
type_job_start=False,
type_job_end=False,
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, event_types=[OBJECT_UPDATED],
type_update=True,
type_delete=False,
type_job_start=False,
type_job_end=False,
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, event_types=[OBJECT_DELETED],
type_update=False,
type_delete=True,
type_job_start=False,
type_job_end=False,
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, event_types=[JOB_STARTED],
type_update=False,
type_delete=False,
type_job_start=True,
type_job_end=False,
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, event_types=[JOB_COMPLETED],
type_update=False,
type_delete=False,
type_job_start=False,
type_job_end=True,
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): def test_event_type(self):
params = {'type_create': True} params = {'event_type': [OBJECT_CREATED, OBJECT_UPDATED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
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)
class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests): class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@ -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 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]), EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
EventRule(name='EventRule 3', type_create=True, action_object=webhooks[2]), 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, 'event_types': [OBJECT_UPDATED, OBJECT_DELETED],
'type_update': True,
'type_delete': True,
'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", f'name,object_types,event_types,action_type,action_object',
"Webhook 4,dcim.site,True,webhook,Webhook 1", 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',
} }

View File

@ -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):

View File

@ -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):

View File

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

View File

@ -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):