mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-22 13:22:24 -06:00
14132 Add EventRule - change webhook and add in script processing to events (#14267)
--------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
@@ -26,7 +26,7 @@ class ContentTypeManager(ContentTypeManager_):
|
||||
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
|
||||
we can find all ContentTypes for models which support webhooks with
|
||||
|
||||
ContentType.objects.with_feature('webhooks')
|
||||
ContentType.objects.with_feature('event_rules')
|
||||
"""
|
||||
if feature not in registry['model_features']:
|
||||
raise KeyError(
|
||||
|
||||
@@ -16,7 +16,7 @@ from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.rqworker import get_queue_for_model, get_rq_retry
|
||||
from utilities.rqworker import get_queue_for_model
|
||||
|
||||
__all__ = (
|
||||
'Job',
|
||||
@@ -168,8 +168,8 @@ class Job(models.Model):
|
||||
self.status = JobStatusChoices.STATUS_RUNNING
|
||||
self.save()
|
||||
|
||||
# Handle webhooks
|
||||
self.trigger_webhooks(event=EVENT_JOB_START)
|
||||
# Handle events
|
||||
self.process_event(event=EVENT_JOB_START)
|
||||
|
||||
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
|
||||
"""
|
||||
@@ -186,8 +186,8 @@ class Job(models.Model):
|
||||
self.completed = timezone.now()
|
||||
self.save()
|
||||
|
||||
# Handle webhooks
|
||||
self.trigger_webhooks(event=EVENT_JOB_END)
|
||||
# Handle events
|
||||
self.process_event(event=EVENT_JOB_END)
|
||||
|
||||
@classmethod
|
||||
def enqueue(cls, func, instance, name='', user=None, schedule_at=None, interval=None, **kwargs):
|
||||
@@ -224,27 +224,18 @@ class Job(models.Model):
|
||||
|
||||
return job
|
||||
|
||||
def trigger_webhooks(self, event):
|
||||
from extras.models import Webhook
|
||||
def process_event(self, event):
|
||||
"""
|
||||
Process any EventRules relevant to the passed job event (i.e. start or stop).
|
||||
"""
|
||||
from extras.models import EventRule
|
||||
from extras.events import process_event_rules
|
||||
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
|
||||
rq_queue = django_rq.get_queue(rq_queue_name, is_async=False)
|
||||
|
||||
# Fetch any webhooks matching this object type and action
|
||||
webhooks = Webhook.objects.filter(
|
||||
# Fetch any event rules matching this object type and action
|
||||
event_rules = EventRule.objects.filter(
|
||||
**{f'type_{event}': True},
|
||||
content_types=self.object_type,
|
||||
enabled=True
|
||||
)
|
||||
|
||||
for webhook in webhooks:
|
||||
rq_queue.enqueue(
|
||||
"extras.webhooks_worker.process_webhook",
|
||||
webhook=webhook,
|
||||
model_name=self.object_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
process_event_rules(event_rules, self.object_type.model, event, self.data, self.user.username)
|
||||
|
||||
@@ -10,15 +10,25 @@ __all__ = [
|
||||
'NestedCustomFieldChoiceSetSerializer',
|
||||
'NestedCustomFieldSerializer',
|
||||
'NestedCustomLinkSerializer',
|
||||
'NestedEventRuleSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
'NestedImageAttachmentSerializer',
|
||||
'NestedJournalEntrySerializer',
|
||||
'NestedSavedFilterSerializer',
|
||||
'NestedScriptSerializer',
|
||||
'NestedTagSerializer', # Defined in netbox.api.serializers
|
||||
'NestedWebhookSerializer',
|
||||
]
|
||||
|
||||
|
||||
class NestedEventRuleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.EventRule
|
||||
fields = ['id', 'url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedWebhookSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
|
||||
|
||||
@@ -105,3 +115,20 @@ class NestedJournalEntrySerializer(WritableNestedSerializer):
|
||||
class Meta:
|
||||
model = models.JournalEntry
|
||||
fields = ['id', 'url', 'display', 'created']
|
||||
|
||||
|
||||
class NestedScriptSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:script-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
name = serializers.CharField(read_only=True)
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Script
|
||||
fields = ['id', 'url', 'display', 'name']
|
||||
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.api.serializers import JobSerializer
|
||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
||||
from core.api.serializers import JobSerializer
|
||||
from core.models import ContentType
|
||||
from dcim.api.nested_serializers import (
|
||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||
)
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
@@ -38,6 +38,7 @@ __all__ = (
|
||||
'CustomFieldSerializer',
|
||||
'CustomLinkSerializer',
|
||||
'DashboardSerializer',
|
||||
'EventRuleSerializer',
|
||||
'ExportTemplateSerializer',
|
||||
'ImageAttachmentSerializer',
|
||||
'JournalEntrySerializer',
|
||||
@@ -56,24 +57,59 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Event Rules
|
||||
#
|
||||
|
||||
class EventRuleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
|
||||
content_types = ContentTypeField(
|
||||
queryset=ContentType.objects.with_feature('event_rules'),
|
||||
many=True
|
||||
)
|
||||
action_type = ChoiceField(choices=EventRuleActionChoices)
|
||||
action_object_type = ContentTypeField(
|
||||
queryset=ContentType.objects.with_feature('event_rules'),
|
||||
)
|
||||
action_object = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = EventRule
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_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',
|
||||
]
|
||||
|
||||
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||
def get_action_object(self, instance):
|
||||
context = {'request': self.context['request']}
|
||||
# We need to manually instantiate the serializer for scripts
|
||||
if instance.action_type == EventRuleActionChoices.SCRIPT:
|
||||
module_id, script_name = instance.action_parameters['script_choice'].split(":", maxsplit=1)
|
||||
script = instance.action_object.scripts[script_name]()
|
||||
return NestedScriptSerializer(script, context=context).data
|
||||
else:
|
||||
serializer = get_serializer_for_model(
|
||||
model=instance.action_object_type.model_class(),
|
||||
prefix=NESTED_SERIALIZER_PREFIX
|
||||
)
|
||||
return serializer(instance.action_object, context=context).data
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
class WebhookSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
|
||||
content_types = ContentTypeField(
|
||||
queryset=ContentType.objects.with_feature('webhooks'),
|
||||
many=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete',
|
||||
'type_job_start', 'type_job_end', 'payload_url', 'enabled', 'http_method', 'http_content_type',
|
||||
'additional_headers', 'body_template', 'secret', 'conditions', 'ssl_verification', 'ca_file_path',
|
||||
'custom_fields', 'tags', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'name', 'payload_url', 'http_method', 'http_content_type', 'additional_headers',
|
||||
'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields', 'tags', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import views
|
||||
router = NetBoxRouter()
|
||||
router.APIRootView = views.ExtrasRootView
|
||||
|
||||
router.register('event-rules', views.EventRuleViewSet)
|
||||
router.register('webhooks', views.WebhookViewSet)
|
||||
router.register('custom-fields', views.CustomFieldViewSet)
|
||||
router.register('custom-field-choice-sets', views.CustomFieldChoiceSetViewSet)
|
||||
|
||||
@@ -37,6 +37,17 @@ class ExtrasRootView(APIRootView):
|
||||
return 'Extras'
|
||||
|
||||
|
||||
#
|
||||
# EventRules
|
||||
#
|
||||
|
||||
class EventRuleViewSet(NetBoxModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = EventRule.objects.all()
|
||||
serializer_class = serializers.EventRuleSerializer
|
||||
filterset_class = filtersets.EventRuleFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
@@ -291,3 +291,18 @@ class DashboardWidgetColorChoices(ChoiceSet):
|
||||
(BLACK, _('Black')),
|
||||
(WHITE, _('White')),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Event Rules
|
||||
#
|
||||
|
||||
class EventRuleActionChoices(ChoiceSet):
|
||||
|
||||
WEBHOOK = 'webhook'
|
||||
SCRIPT = 'script'
|
||||
|
||||
CHOICES = (
|
||||
(WEBHOOK, _('Webhook')),
|
||||
(SCRIPT, _('Script')),
|
||||
)
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from netbox.context import current_request, webhooks_queue
|
||||
from .webhooks import flush_webhooks
|
||||
from netbox.context import current_request, events_queue
|
||||
from .events import flush_events
|
||||
|
||||
|
||||
@contextmanager
|
||||
def change_logging(request):
|
||||
def event_tracking(request):
|
||||
"""
|
||||
Enable change logging by connecting the appropriate signals to their receivers before code is run, and
|
||||
disconnecting them afterward.
|
||||
Queue interesting events in memory while processing a request, then flush that queue for processing by the
|
||||
events pipline before returning the response.
|
||||
|
||||
:param request: WSGIRequest object with a unique `id` set
|
||||
"""
|
||||
current_request.set(request)
|
||||
webhooks_queue.set([])
|
||||
events_queue.set([])
|
||||
|
||||
yield
|
||||
|
||||
# Flush queued webhooks to RQ
|
||||
flush_webhooks(webhooks_queue.get())
|
||||
flush_events(events_queue.get())
|
||||
|
||||
# Clear context vars
|
||||
current_request.set(None)
|
||||
webhooks_queue.set([])
|
||||
events_queue.set([])
|
||||
|
||||
178
netbox/extras/events.py
Normal file
178
netbox/extras/events.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django_rq import get_queue
|
||||
|
||||
from core.models import Job
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.registry import registry
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.rqworker import get_rq_retry
|
||||
from utilities.utils import serialize_object
|
||||
from .choices import *
|
||||
from .models import EventRule, ScriptModule
|
||||
|
||||
logger = logging.getLogger('netbox.events_processor')
|
||||
|
||||
|
||||
def serialize_for_event(instance):
|
||||
"""
|
||||
Return a serialized representation of the given instance suitable for use in a queued event.
|
||||
"""
|
||||
serializer_class = get_serializer_for_model(instance.__class__)
|
||||
serializer_context = {
|
||||
'request': None,
|
||||
}
|
||||
serializer = serializer_class(instance, context=serializer_context)
|
||||
|
||||
return serializer.data
|
||||
|
||||
|
||||
def get_snapshots(instance, action):
|
||||
snapshots = {
|
||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||
'postchange': None,
|
||||
}
|
||||
if action != ObjectChangeActionChoices.ACTION_DELETE:
|
||||
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
|
||||
if hasattr(instance, 'serialize_object'):
|
||||
snapshots['postchange'] = instance.serialize_object()
|
||||
else:
|
||||
snapshots['postchange'] = serialize_object(instance)
|
||||
|
||||
return snapshots
|
||||
|
||||
|
||||
def enqueue_object(queue, instance, user, request_id, action):
|
||||
"""
|
||||
Enqueue a serialized representation of a created/updated/deleted object for the processing of
|
||||
events once the request has completed.
|
||||
"""
|
||||
# Determine whether this type of object supports event rules
|
||||
app_label = instance._meta.app_label
|
||||
model_name = instance._meta.model_name
|
||||
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
|
||||
return
|
||||
|
||||
queue.append({
|
||||
'content_type': ContentType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event': action,
|
||||
'data': serialize_for_event(instance),
|
||||
'snapshots': get_snapshots(instance, action),
|
||||
'username': user.username,
|
||||
'request_id': request_id
|
||||
})
|
||||
|
||||
|
||||
def process_event_rules(event_rules, model_name, event, data, username, snapshots=None, request_id=None):
|
||||
try:
|
||||
user = get_user_model().objects.get(username=username)
|
||||
except ObjectDoesNotExist:
|
||||
user = None
|
||||
|
||||
for event_rule in event_rules:
|
||||
|
||||
# Evaluate event rule conditions (if any)
|
||||
if not event_rule.eval_conditions(data):
|
||||
return
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
|
||||
# Select the appropriate RQ queue
|
||||
queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
|
||||
rq_queue = get_queue(queue_name)
|
||||
|
||||
# Compile the task parameters
|
||||
params = {
|
||||
"event_rule": event_rule,
|
||||
"model_name": model_name,
|
||||
"event": event,
|
||||
"data": data,
|
||||
"snapshots": snapshots,
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"username": username,
|
||||
"retry": get_rq_retry()
|
||||
}
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request_id:
|
||||
params["request_id"] = request_id
|
||||
|
||||
# Enqueue the task
|
||||
rq_queue.enqueue(
|
||||
"extras.webhooks_worker.process_webhook",
|
||||
**params
|
||||
)
|
||||
|
||||
# Scripts
|
||||
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
|
||||
# Resolve the script from action parameters
|
||||
script_module = event_rule.action_object
|
||||
_, script_name = event_rule.action_parameters['script_choice'].split(":", maxsplit=1)
|
||||
script = script_module.scripts[script_name]()
|
||||
|
||||
# Enqueue a Job to record the script's execution
|
||||
Job.enqueue(
|
||||
"extras.scripts.run_script",
|
||||
instance=script_module,
|
||||
name=script.class_name,
|
||||
user=user,
|
||||
data=data
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
|
||||
|
||||
|
||||
def process_event_queue(events):
|
||||
"""
|
||||
Flush a list of object representation to RQ for EventRule processing.
|
||||
"""
|
||||
events_cache = {
|
||||
'type_create': {},
|
||||
'type_update': {},
|
||||
'type_delete': {},
|
||||
}
|
||||
|
||||
for data in events:
|
||||
action_flag = {
|
||||
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
||||
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
||||
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
||||
}[data['event']]
|
||||
content_type = data['content_type']
|
||||
|
||||
# Cache applicable Event Rules
|
||||
if content_type not in events_cache[action_flag]:
|
||||
events_cache[action_flag][content_type] = EventRule.objects.filter(
|
||||
**{action_flag: True},
|
||||
content_types=content_type,
|
||||
enabled=True
|
||||
)
|
||||
event_rules = events_cache[action_flag][content_type]
|
||||
|
||||
process_event_rules(
|
||||
event_rules, content_type.model, data['event'], data['data'], data['username'],
|
||||
snapshots=data['snapshots'], request_id=data['request_id']
|
||||
)
|
||||
|
||||
|
||||
def flush_events(queue):
|
||||
"""
|
||||
Flush a list of object representation to RQ for webhook processing.
|
||||
"""
|
||||
if queue:
|
||||
for name in settings.EVENTS_PIPELINE:
|
||||
try:
|
||||
func = import_string(name)
|
||||
func(queue)
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot import events pipeline {name} error: {e}")
|
||||
@@ -22,6 +22,7 @@ __all__ = (
|
||||
'CustomFieldChoiceSetFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
'CustomLinkFilterSet',
|
||||
'EventRuleFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'JournalEntryFilterSet',
|
||||
@@ -38,19 +39,18 @@ class WebhookFilterSet(NetBoxModelFilterSet):
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
content_type_id = MultiValueNumberFilter(
|
||||
field_name='content_types__id'
|
||||
)
|
||||
content_types = ContentTypeFilter()
|
||||
http_method = django_filters.MultipleChoiceFilter(
|
||||
choices=WebhookHttpMethodChoices
|
||||
)
|
||||
payload_url = MultiValueCharFilter(
|
||||
lookup_expr='icontains'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'payload_url',
|
||||
'enabled', 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||
'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification',
|
||||
'ca_file_path',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -62,6 +62,38 @@ class WebhookFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class EventRuleFilterSet(NetBoxModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
content_type_id = MultiValueNumberFilter(
|
||||
field_name='content_types__id'
|
||||
)
|
||||
content_types = ContentTypeFilter()
|
||||
action_type = django_filters.MultipleChoiceFilter(
|
||||
choices=EventRuleActionChoices
|
||||
)
|
||||
action_object_type = ContentTypeFilter()
|
||||
action_object_id = MultiValueNumberFilter()
|
||||
|
||||
class Meta:
|
||||
model = EventRule
|
||||
fields = [
|
||||
'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled',
|
||||
'action_type', 'description',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@@ -14,6 +14,7 @@ __all__ = (
|
||||
'CustomFieldBulkEditForm',
|
||||
'CustomFieldChoiceSetBulkEditForm',
|
||||
'CustomLinkBulkEditForm',
|
||||
'EventRuleBulkEditForm',
|
||||
'ExportTemplateBulkEditForm',
|
||||
'JournalEntryBulkEditForm',
|
||||
'SavedFilterBulkEditForm',
|
||||
@@ -177,6 +178,39 @@ class WebhookBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=Webhook.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
http_method = forms.ChoiceField(
|
||||
choices=add_blank_choice(WebhookHttpMethodChoices),
|
||||
required=False,
|
||||
label=_('HTTP method')
|
||||
)
|
||||
payload_url = forms.CharField(
|
||||
required=False,
|
||||
label=_('Payload URL')
|
||||
)
|
||||
ssl_verification = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('SSL verification')
|
||||
)
|
||||
secret = forms.CharField(
|
||||
label=_('Secret'),
|
||||
required=False
|
||||
)
|
||||
ca_file_path = forms.CharField(
|
||||
required=False,
|
||||
label=_('CA file path')
|
||||
)
|
||||
|
||||
nullable_fields = ('secret', 'ca_file_path')
|
||||
|
||||
|
||||
class EventRuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = EventRule
|
||||
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=EventRule.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
@@ -207,30 +241,8 @@ class WebhookBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
http_method = forms.ChoiceField(
|
||||
choices=add_blank_choice(WebhookHttpMethodChoices),
|
||||
required=False,
|
||||
label=_('HTTP method')
|
||||
)
|
||||
payload_url = forms.CharField(
|
||||
required=False,
|
||||
label=_('Payload URL')
|
||||
)
|
||||
ssl_verification = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('SSL verification')
|
||||
)
|
||||
secret = forms.CharField(
|
||||
label=_('Secret'),
|
||||
required=False
|
||||
)
|
||||
ca_file_path = forms.CharField(
|
||||
required=False,
|
||||
label=_('CA file path')
|
||||
)
|
||||
|
||||
nullable_fields = ('secret', 'conditions', 'ca_file_path')
|
||||
nullable_fields = ('conditions',)
|
||||
|
||||
|
||||
class TagBulkEditForm(BulkEditForm):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import forms
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -17,6 +18,7 @@ __all__ = (
|
||||
'CustomFieldChoiceSetImportForm',
|
||||
'CustomFieldImportForm',
|
||||
'CustomLinkImportForm',
|
||||
'EventRuleImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
'JournalEntryImportForm',
|
||||
'SavedFilterImportForm',
|
||||
@@ -143,21 +145,62 @@ class SavedFilterImportForm(CSVModelForm):
|
||||
|
||||
|
||||
class WebhookImportForm(NetBoxModelImportForm):
|
||||
content_types = CSVMultipleContentTypeField(
|
||||
label=_('Content types'),
|
||||
queryset=ContentType.objects.with_feature('webhooks'),
|
||||
help_text=_("One or more assigned object types")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = (
|
||||
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'type_job_start',
|
||||
'type_job_end', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template',
|
||||
'name', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template',
|
||||
'secret', 'ssl_verification', 'ca_file_path', 'tags'
|
||||
)
|
||||
|
||||
|
||||
class EventRuleImportForm(NetBoxModelImportForm):
|
||||
content_types = CSVMultipleContentTypeField(
|
||||
label=_('Content types'),
|
||||
queryset=ContentType.objects.with_feature('event_rules'),
|
||||
help_text=_("One or more assigned object types")
|
||||
)
|
||||
action_object = forms.CharField(
|
||||
label=_('Action object'),
|
||||
required=True,
|
||||
help_text=_('Webhook name or script as dotted path module.Class')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = EventRule
|
||||
fields = (
|
||||
'name', 'description', 'enabled', 'conditions', 'content_types', 'type_create', 'type_update',
|
||||
'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
action_object = self.cleaned_data.get('action_object')
|
||||
action_type = self.cleaned_data.get('action_type')
|
||||
if action_object and action_type:
|
||||
if action_type == EventRuleActionChoices.WEBHOOK:
|
||||
try:
|
||||
webhook = Webhook.objects.get(name=action_object)
|
||||
except Webhook.ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Webhook {action_object} not found")
|
||||
self.instance.action_object = webhook
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
from extras.scripts import get_module_and_script
|
||||
module_name, script_name = action_object.split('.', 1)
|
||||
try:
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"Script {action_object} not found")
|
||||
self.instance.action_object = module
|
||||
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
|
||||
self.instance.action_parameters = {
|
||||
'script_choice': f"{str(module.pk)}:{script_name}",
|
||||
'script_name': script.name,
|
||||
'script_full_name': script.full_name,
|
||||
}
|
||||
|
||||
|
||||
class TagImportForm(CSVModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ __all__ = (
|
||||
'CustomFieldChoiceSetFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
'EventRuleFilterForm',
|
||||
'ExportTemplateFilterForm',
|
||||
'ImageAttachmentFilterForm',
|
||||
'JournalEntryFilterForm',
|
||||
@@ -223,23 +224,45 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
class WebhookFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Webhook
|
||||
tag = TagFilterField(model)
|
||||
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('content_type_id', 'http_method', 'enabled')),
|
||||
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||
(_('Attributes'), ('payload_url', 'http_method', 'http_content_type')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.with_feature('webhooks'),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
http_content_type = forms.CharField(
|
||||
label=_('HTTP content type'),
|
||||
required=False
|
||||
)
|
||||
payload_url = forms.CharField(
|
||||
label=_('Payload URL'),
|
||||
required=False
|
||||
)
|
||||
http_method = forms.MultipleChoiceField(
|
||||
choices=WebhookHttpMethodChoices,
|
||||
required=False,
|
||||
label=_('HTTP method')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class EventRuleFilterForm(NetBoxModelFilterSetForm):
|
||||
model = EventRule
|
||||
tag = TagFilterField(model)
|
||||
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('content_type_id', 'action_type', 'enabled')),
|
||||
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.with_feature('event_rules'),
|
||||
required=False,
|
||||
label=_('Object type')
|
||||
)
|
||||
action_type = forms.ChoiceField(
|
||||
choices=add_blank_choice(EventRuleActionChoices),
|
||||
required=False,
|
||||
label=_('Action type')
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -11,12 +12,12 @@ from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice, get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
)
|
||||
from utilities.forms.widgets import ChoicesWidget
|
||||
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
__all__ = (
|
||||
@@ -26,6 +27,7 @@ __all__ = (
|
||||
'CustomFieldChoiceSetForm',
|
||||
'CustomFieldForm',
|
||||
'CustomLinkForm',
|
||||
'EventRuleForm',
|
||||
'ExportTemplateForm',
|
||||
'ImageAttachmentForm',
|
||||
'JournalEntryForm',
|
||||
@@ -211,24 +213,59 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
||||
class WebhookForm(NetBoxModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
label=_('Content types'),
|
||||
queryset=ContentType.objects.with_feature('webhooks')
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(_('Webhook'), ('name', 'content_types', 'enabled', 'tags')),
|
||||
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||
(_('Webhook'), ('name', 'tags',)),
|
||||
(_('HTTP Request'), (
|
||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
)),
|
||||
(_('Conditions'), ('conditions',)),
|
||||
(_('SSL'), ('ssl_verification', 'ca_file_path')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
|
||||
class EventRuleForm(NetBoxModelForm):
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
label=_('Content types'),
|
||||
queryset=ContentType.objects.with_feature('event_rules'),
|
||||
)
|
||||
action_choice = forms.ChoiceField(
|
||||
label=_('Action choice'),
|
||||
choices=[]
|
||||
)
|
||||
conditions = JSONField(
|
||||
required=False,
|
||||
help_text=_('Enter conditions in <a href="https://json.org/">JSON</a> format.')
|
||||
)
|
||||
action_data = JSONField(
|
||||
required=False,
|
||||
help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),
|
||||
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||
(_('Conditions'), ('conditions',)),
|
||||
(_('Action'), (
|
||||
'action_type', 'action_choice', 'action_parameters', 'action_object_type', 'action_object_id',
|
||||
'action_data',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = EventRule
|
||||
fields = (
|
||||
'content_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_parameters', 'action_data', 'comments', 'tags'
|
||||
)
|
||||
labels = {
|
||||
'type_create': _('Creations'),
|
||||
'type_update': _('Updates'),
|
||||
@@ -237,11 +274,76 @@ class WebhookForm(NetBoxModelForm):
|
||||
'type_job_end': _('Job terminations'),
|
||||
}
|
||||
widgets = {
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'action_type': HTMXSelect(),
|
||||
'action_object_type': forms.HiddenInput,
|
||||
'action_object_id': forms.HiddenInput,
|
||||
'action_parameters': forms.HiddenInput,
|
||||
}
|
||||
|
||||
def init_script_choice(self):
|
||||
choices = []
|
||||
for module in ScriptModule.objects.all():
|
||||
scripts = []
|
||||
for script_name in module.scripts.keys():
|
||||
name = f"{str(module.pk)}:{script_name}"
|
||||
scripts.append((name, script_name))
|
||||
|
||||
if scripts:
|
||||
choices.append((str(module), scripts))
|
||||
|
||||
self.fields['action_choice'].choices = choices
|
||||
parameters = get_field_value(self, 'action_parameters')
|
||||
initial = None
|
||||
if parameters and 'script_choice' in parameters:
|
||||
initial = parameters['script_choice']
|
||||
self.fields['action_choice'].initial = initial
|
||||
|
||||
def init_webhook_choice(self):
|
||||
initial = None
|
||||
if self.fields['action_object_type'] and get_field_value(self, 'action_object_id'):
|
||||
initial = Webhook.objects.get(pk=get_field_value(self, 'action_object_id'))
|
||||
self.fields['action_choice'] = DynamicModelChoiceField(
|
||||
label=_('Webhook'),
|
||||
queryset=Webhook.objects.all(),
|
||||
required=True,
|
||||
initial=initial
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['action_object_type'].required = False
|
||||
self.fields['action_object_id'].required = False
|
||||
|
||||
# Determine the action type
|
||||
action_type = get_field_value(self, 'action_type')
|
||||
|
||||
if action_type == EventRuleActionChoices.WEBHOOK:
|
||||
self.init_webhook_choice()
|
||||
elif action_type == EventRuleActionChoices.SCRIPT:
|
||||
self.init_script_choice()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
action_choice = self.cleaned_data.get('action_choice')
|
||||
if self.cleaned_data.get('action_type') == EventRuleActionChoices.WEBHOOK:
|
||||
self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(action_choice)
|
||||
self.cleaned_data['action_object_id'] = action_choice.id
|
||||
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
|
||||
module_id, script_name = action_choice.split(":", maxsplit=1)
|
||||
script_module = ScriptModule.objects.get(pk=module_id)
|
||||
self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(script_module, for_concrete_model=False)
|
||||
self.cleaned_data['action_object_id'] = script_module.id
|
||||
script = script_module.scripts[script_name]()
|
||||
self.cleaned_data['action_parameters'] = {
|
||||
'script_choice': action_choice,
|
||||
'script_name': script.name,
|
||||
'script_full_name': script.full_name,
|
||||
}
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class TagForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
@@ -72,3 +72,9 @@ class ExtrasQuery(graphene.ObjectType):
|
||||
|
||||
def resolve_webhook_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.Webhook.objects.all(), info)
|
||||
|
||||
event_rule = ObjectField(EventRuleType)
|
||||
event_rule_list = ObjectListField(EventRuleType)
|
||||
|
||||
def resolve_eventrule_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.EventRule.objects.all(), info)
|
||||
|
||||
@@ -8,6 +8,7 @@ __all__ = (
|
||||
'CustomFieldChoiceSetType',
|
||||
'CustomFieldType',
|
||||
'CustomLinkType',
|
||||
'EventRuleType',
|
||||
'ExportTemplateType',
|
||||
'ImageAttachmentType',
|
||||
'JournalEntryType',
|
||||
@@ -110,5 +111,12 @@ class WebhookType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.Webhook
|
||||
exclude = ('content_types', )
|
||||
filterset_class = filtersets.WebhookFilterSet
|
||||
|
||||
|
||||
class EventRuleType(OrganizationalObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.EventRule
|
||||
exclude = ('content_types', )
|
||||
filterset_class = filtersets.EventRuleFilterSet
|
||||
|
||||
@@ -11,9 +11,9 @@ from django.db import transaction
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.context_managers import change_logging
|
||||
from extras.context_managers import event_tracking
|
||||
from extras.scripts import get_module_and_script
|
||||
from extras.signals import clear_webhooks
|
||||
from extras.signals import clear_events
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.utils import NetBoxFakeRequest
|
||||
|
||||
@@ -37,7 +37,7 @@ class Command(BaseCommand):
|
||||
def _run_script():
|
||||
"""
|
||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||
the change_logging context manager (which is bypassed if commit == False).
|
||||
the event_tracking context manager (which is bypassed if commit == False).
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
@@ -47,7 +47,7 @@ class Command(BaseCommand):
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
clear_events.send(request)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
@@ -57,7 +57,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
clear_webhooks.send(request)
|
||||
clear_events.send(request)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
|
||||
|
||||
@@ -136,9 +136,9 @@ class Command(BaseCommand):
|
||||
logger.info(f"Running script (commit={commit})")
|
||||
script.request = request
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, webhooks, etc.
|
||||
with change_logging(request):
|
||||
with event_tracking(request):
|
||||
_run_script()
|
||||
else:
|
||||
logger.error('Data is not valid:')
|
||||
|
||||
127
netbox/extras/migrations/0101_eventrule.py
Normal file
127
netbox/extras/migrations/0101_eventrule.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import migrations, models
|
||||
|
||||
import utilities.json
|
||||
from extras.choices import *
|
||||
|
||||
|
||||
def move_webhooks(apps, schema_editor):
|
||||
Webhook = apps.get_model("extras", "Webhook")
|
||||
EventRule = apps.get_model("extras", "EventRule")
|
||||
|
||||
for webhook in Webhook.objects.all():
|
||||
event = EventRule()
|
||||
|
||||
event.name = webhook.name
|
||||
event.type_create = webhook.type_create
|
||||
event.type_update = webhook.type_update
|
||||
event.type_delete = webhook.type_delete
|
||||
event.type_job_start = webhook.type_job_start
|
||||
event.type_job_end = webhook.type_job_end
|
||||
event.enabled = webhook.enabled
|
||||
event.conditions = webhook.conditions
|
||||
|
||||
event.action_type = EventRuleActionChoices.WEBHOOK
|
||||
event.action_object_type_id = ContentType.objects.get_for_model(webhook).id
|
||||
event.action_object_id = webhook.id
|
||||
event.save()
|
||||
event.content_types.add(*webhook.content_types.all())
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0100_customfield_ui_attrs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EventRule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
(
|
||||
'custom_field_data',
|
||||
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
||||
),
|
||||
('name', models.CharField(max_length=150, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('type_create', models.BooleanField(default=False)),
|
||||
('type_update', models.BooleanField(default=False)),
|
||||
('type_delete', models.BooleanField(default=False)),
|
||||
('type_job_start', models.BooleanField(default=False)),
|
||||
('type_job_end', models.BooleanField(default=False)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('conditions', models.JSONField(blank=True, null=True)),
|
||||
('action_type', models.CharField(default='webhook', max_length=30)),
|
||||
('action_object_id', models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
('action_parameters', models.JSONField(blank=True, null=True)),
|
||||
('action_data', models.JSONField(blank=True, null=True)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'eventrule',
|
||||
'verbose_name_plural': 'eventrules',
|
||||
'ordering': ('name',),
|
||||
},
|
||||
),
|
||||
migrations.RunPython(move_webhooks),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='webhook',
|
||||
name='extras_webhook_unique_payload_url_types',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='conditions',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='content_types',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='enabled',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='type_create',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='type_delete',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='type_job_end',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='type_job_start',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='webhook',
|
||||
name='type_update',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventrule',
|
||||
name='action_object_type',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='eventrule_actions',
|
||||
to='contenttypes.contenttype',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventrule',
|
||||
name='content_types',
|
||||
field=models.ManyToManyField(related_name='eventrules', to='contenttypes.contenttype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventrule',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
@@ -15,7 +15,7 @@ def update_content_type(apps, schema_editor):
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0100_customfield_ui_attrs'),
|
||||
('extras', '0101_eventrule'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
@@ -28,6 +28,7 @@ from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
||||
__all__ = (
|
||||
'Bookmark',
|
||||
'CustomLink',
|
||||
'EventRule',
|
||||
'ExportTemplate',
|
||||
'ImageAttachment',
|
||||
'JournalEntry',
|
||||
@@ -36,23 +37,28 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
||||
Each Webhook can be limited to firing only on certain actions or certain object types.
|
||||
An EventRule defines an action to be taken automatically in response to a specific set of events, such as when a
|
||||
specific type of object is created, modified, or deleted. The action to be taken might entail transmitting a
|
||||
webhook or executing a custom script.
|
||||
"""
|
||||
content_types = models.ManyToManyField(
|
||||
to='contenttypes.ContentType',
|
||||
related_name='webhooks',
|
||||
related_name='eventrules',
|
||||
verbose_name=_('object types'),
|
||||
help_text=_("The object(s) to which this Webhook applies.")
|
||||
help_text=_("The object(s) to which this rule applies.")
|
||||
)
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=150,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
verbose_name=_('description'),
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
type_create = models.BooleanField(
|
||||
verbose_name=_('on create'),
|
||||
default=False,
|
||||
@@ -78,6 +84,104 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
default=False,
|
||||
help_text=_("Triggers when a job for a matching object terminates.")
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True
|
||||
)
|
||||
conditions = models.JSONField(
|
||||
verbose_name=_('conditions'),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("A set of conditions which determine whether the event will be generated.")
|
||||
)
|
||||
|
||||
# Action to take
|
||||
action_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=EventRuleActionChoices,
|
||||
default=EventRuleActionChoices.WEBHOOK,
|
||||
verbose_name=_('action type')
|
||||
)
|
||||
action_object_type = models.ForeignKey(
|
||||
to='contenttypes.ContentType',
|
||||
related_name='eventrule_actions',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
action_object_id = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
action_object = GenericForeignKey(
|
||||
ct_field='action_object_type',
|
||||
fk_field='action_object_id'
|
||||
)
|
||||
# internal (not show in UI) - used by scripts to store function name
|
||||
action_parameters = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
action_data = models.JSONField(
|
||||
verbose_name=_('parameters'),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("Parameters to pass to the action.")
|
||||
)
|
||||
comments = models.TextField(
|
||||
verbose_name=_('comments'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('event rule')
|
||||
verbose_name_plural = _('event rules')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:eventrule', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
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
|
||||
if self.conditions:
|
||||
try:
|
||||
ConditionSet(self.conditions)
|
||||
except ValueError as e:
|
||||
raise ValidationError({'conditions': e})
|
||||
|
||||
def eval_conditions(self, data):
|
||||
"""
|
||||
Test whether the given data meets the conditions of the event rule (if any). Return True
|
||||
if met or no conditions are specified.
|
||||
"""
|
||||
if not self.conditions:
|
||||
return True
|
||||
|
||||
return ConditionSet(self.conditions).eval(data)
|
||||
|
||||
|
||||
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
||||
Each Webhook can be limited to firing only on certain actions or certain object types.
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=150,
|
||||
unique=True
|
||||
)
|
||||
payload_url = models.CharField(
|
||||
max_length=500,
|
||||
verbose_name=_('URL'),
|
||||
@@ -86,10 +190,6 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
"processing is supported with the same context as the request body."
|
||||
)
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
verbose_name=_('enabled'),
|
||||
default=True
|
||||
)
|
||||
http_method = models.CharField(
|
||||
max_length=30,
|
||||
choices=WebhookHttpMethodChoices,
|
||||
@@ -132,12 +232,6 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
"digest of the payload body using the secret as the key. The secret is not transmitted in the request."
|
||||
)
|
||||
)
|
||||
conditions = models.JSONField(
|
||||
verbose_name=_('conditions'),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("A set of conditions which determine whether the webhook will be generated.")
|
||||
)
|
||||
ssl_verification = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('SSL verification'),
|
||||
@@ -152,15 +246,14 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
"The specific CA certificate file to use for SSL verification. Leave blank to use the system defaults."
|
||||
)
|
||||
)
|
||||
events = GenericRelation(
|
||||
EventRule,
|
||||
content_type_field='action_object_type',
|
||||
object_id_field='action_object_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('payload_url', 'type_create', 'type_update', 'type_delete'),
|
||||
name='%(app_label)s_%(class)s_unique_payload_url_types'
|
||||
),
|
||||
)
|
||||
verbose_name = _('webhook')
|
||||
verbose_name_plural = _('webhooks')
|
||||
|
||||
@@ -177,20 +270,6 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
def clean(self):
|
||||
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.")
|
||||
)
|
||||
|
||||
if self.conditions:
|
||||
try:
|
||||
ConditionSet(self.conditions)
|
||||
except ValueError as e:
|
||||
raise ValidationError({'conditions': e})
|
||||
|
||||
# CA file path requires SSL verification enabled
|
||||
if not self.ssl_verification and self.ca_file_path:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
from extras.utils import is_report
|
||||
from netbox.models.features import JobsMixin, WebhooksMixin
|
||||
from netbox.models.features import JobsMixin, EventRulesMixin
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from .mixins import PythonModuleMixin
|
||||
|
||||
@@ -21,7 +21,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class Report(WebhooksMixin, models.Model):
|
||||
class Report(EventRulesMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
from extras.utils import is_script
|
||||
from netbox.models.features import JobsMixin, WebhooksMixin
|
||||
from netbox.models.features import JobsMixin, EventRulesMixin
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from .mixins import PythonModuleMixin
|
||||
|
||||
@@ -21,7 +21,7 @@ __all__ = (
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
class Script(WebhooksMixin, models.Model):
|
||||
class Script(EventRulesMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
|
||||
@@ -17,13 +17,13 @@ from core.models import Job
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import LogLevelChoices
|
||||
from extras.models import ScriptModule
|
||||
from extras.signals import clear_webhooks
|
||||
from extras.signals import clear_events
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .context_managers import change_logging
|
||||
from .context_managers import event_tracking
|
||||
from .forms import ScriptForm
|
||||
|
||||
__all__ = (
|
||||
@@ -472,10 +472,16 @@ def get_module_and_script(module_name, script_name):
|
||||
return module, script
|
||||
|
||||
|
||||
def run_script(data, request, job, commit=True, **kwargs):
|
||||
def run_script(data, job, request=None, commit=True, **kwargs):
|
||||
"""
|
||||
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
|
||||
exists outside the Script class to ensure it cannot be overridden by a script author.
|
||||
|
||||
Args:
|
||||
data: A dictionary of data to be passed to the script upon execution
|
||||
job: The Job associated with this execution
|
||||
request: The WSGI request associated with this execution (if any)
|
||||
commit: Passed through to Script.run()
|
||||
"""
|
||||
job.start()
|
||||
|
||||
@@ -486,9 +492,10 @@ def run_script(data, request, job, commit=True, **kwargs):
|
||||
logger.info(f"Running script (commit={commit})")
|
||||
|
||||
# Add files to form data
|
||||
files = request.FILES
|
||||
for field_name, fileobj in files.items():
|
||||
data[field_name] = fileobj
|
||||
if request:
|
||||
files = request.FILES
|
||||
for field_name, fileobj in files.items():
|
||||
data[field_name] = fileobj
|
||||
|
||||
# Add the current request as a property of the script
|
||||
script.request = request
|
||||
@@ -496,7 +503,7 @@ def run_script(data, request, job, commit=True, **kwargs):
|
||||
def _run_script():
|
||||
"""
|
||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||
the change_logging context manager (which is bypassed if commit == False).
|
||||
the event_tracking context manager (which is bypassed if commit == False).
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
@@ -506,7 +513,8 @@ def run_script(data, request, job, commit=True, **kwargs):
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
if request:
|
||||
clear_events.send(request)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
@@ -520,14 +528,15 @@ def run_script(data, request, job, commit=True, **kwargs):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
|
||||
clear_webhooks.send(request)
|
||||
if request:
|
||||
clear_events.send(request)
|
||||
|
||||
logger.info(f"Script completed in {job.duration}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
|
||||
# change logging, webhooks, etc.
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
with change_logging(request):
|
||||
with event_tracking(request):
|
||||
_run_script()
|
||||
else:
|
||||
_run_script()
|
||||
|
||||
@@ -10,19 +10,19 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from extras.validators import CustomValidator
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, webhooks_queue
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .events import enqueue_object, get_snapshots, serialize_for_event
|
||||
from .models import CustomField, ObjectChange, TaggedItem
|
||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
|
||||
#
|
||||
# Change logging/webhooks
|
||||
#
|
||||
|
||||
# Define a custom signal that can be sent to clear any queued webhooks
|
||||
clear_webhooks = Signal()
|
||||
# Define a custom signal that can be sent to clear any queued events
|
||||
clear_events = Signal()
|
||||
|
||||
|
||||
def is_same_object(instance, webhook_data, request_id):
|
||||
@@ -81,14 +81,14 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
queue = webhooks_queue.get()
|
||||
queue = events_queue.get()
|
||||
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
|
||||
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
|
||||
queue[-1]['data'] = serialize_for_webhook(instance)
|
||||
queue[-1]['data'] = serialize_for_event(instance)
|
||||
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||
else:
|
||||
enqueue_object(queue, instance, request.user, request.id, action)
|
||||
webhooks_queue.set(queue)
|
||||
events_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
@@ -117,22 +117,22 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
queue = webhooks_queue.get()
|
||||
queue = events_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
webhooks_queue.set(queue)
|
||||
events_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
@receiver(clear_webhooks)
|
||||
def clear_webhook_queue(sender, **kwargs):
|
||||
@receiver(clear_events)
|
||||
def clear_events_queue(sender, **kwargs):
|
||||
"""
|
||||
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
|
||||
Delete any queued events (e.g. because of an aborted bulk transaction)
|
||||
"""
|
||||
logger = logging.getLogger('webhooks')
|
||||
logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})")
|
||||
webhooks_queue.set([])
|
||||
logger = logging.getLogger('events')
|
||||
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
|
||||
events_queue.set([])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -15,6 +15,7 @@ __all__ = (
|
||||
'CustomFieldChoiceSetTable',
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
'EventRuleTable',
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
@@ -250,6 +251,32 @@ class WebhookTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
ssl_validation = columns.BooleanColumn(
|
||||
verbose_name=_('SSL Validation')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:webhook_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Webhook
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'http_method', 'payload_url', 'http_content_type', 'secret', 'ssl_verification',
|
||||
'ca_file_path', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'http_method', 'payload_url',
|
||||
)
|
||||
|
||||
|
||||
class EventRuleTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
action_type = tables.Column(
|
||||
verbose_name=_('Action Type'),
|
||||
)
|
||||
content_types = columns.ContentTypesColumn(
|
||||
verbose_name=_('Content Types'),
|
||||
)
|
||||
@@ -271,23 +298,19 @@ class WebhookTable(NetBoxTable):
|
||||
type_job_end = columns.BooleanColumn(
|
||||
verbose_name=_('Job End')
|
||||
)
|
||||
ssl_validation = columns.BooleanColumn(
|
||||
verbose_name=_('SSL Validation')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:webhook_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Webhook
|
||||
model = EventRule
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete',
|
||||
'type_job_start', 'type_job_end', 'http_method', 'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||
'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'content_types', 'type_create', 'type_update',
|
||||
'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start',
|
||||
'type_job_end', 'http_method', 'payload_url',
|
||||
'pk', 'name', 'enabled', 'action_type', 'content_types', 'type_create', 'type_update', 'type_delete',
|
||||
'type_job_start', 'type_job_end',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
@@ -32,21 +33,15 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'content_types': ['dcim.device', 'dcim.devicetype'],
|
||||
'name': 'Webhook 4',
|
||||
'type_create': True,
|
||||
'payload_url': 'http://example.com/?4',
|
||||
},
|
||||
{
|
||||
'content_types': ['dcim.device', 'dcim.devicetype'],
|
||||
'name': 'Webhook 5',
|
||||
'type_update': True,
|
||||
'payload_url': 'http://example.com/?5',
|
||||
},
|
||||
{
|
||||
'content_types': ['dcim.device', 'dcim.devicetype'],
|
||||
'name': 'Webhook 6',
|
||||
'type_delete': True,
|
||||
'payload_url': 'http://example.com/?6',
|
||||
},
|
||||
]
|
||||
@@ -56,29 +51,100 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
rack_ct = ContentType.objects.get_for_model(Rack)
|
||||
|
||||
webhooks = (
|
||||
Webhook(
|
||||
name='Webhook 1',
|
||||
type_create=True,
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 2',
|
||||
type_update=True,
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 3',
|
||||
type_delete=True,
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
)
|
||||
Webhook.objects.bulk_create(webhooks)
|
||||
for webhook in webhooks:
|
||||
webhook.content_types.add(site_ct, rack_ct)
|
||||
|
||||
|
||||
class EventRuleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = EventRule
|
||||
brief_fields = ['display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'enabled': False,
|
||||
'description': 'New description',
|
||||
}
|
||||
update_data = {
|
||||
'name': 'Event Rule X',
|
||||
'enabled': False,
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
webhooks = (
|
||||
Webhook(
|
||||
name='Webhook 1',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 2',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 3',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 4',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 5',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 6',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
)
|
||||
Webhook.objects.bulk_create(webhooks)
|
||||
|
||||
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.objects.bulk_create(event_rules)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'EventRule 4',
|
||||
'content_types': ['dcim.device', 'dcim.devicetype'],
|
||||
'type_create': True,
|
||||
'action_type': EventRuleActionChoices.WEBHOOK,
|
||||
'action_object_type': 'extras.webhook',
|
||||
'action_object_id': webhooks[3].pk,
|
||||
},
|
||||
{
|
||||
'name': 'EventRule 5',
|
||||
'content_types': ['dcim.device', 'dcim.devicetype'],
|
||||
'type_create': True,
|
||||
'action_type': EventRuleActionChoices.WEBHOOK,
|
||||
'action_object_type': 'extras.webhook',
|
||||
'action_object_id': webhooks[4].pk,
|
||||
},
|
||||
{
|
||||
'name': 'EventRule 6',
|
||||
'content_types': ['dcim.device', 'dcim.devicetype'],
|
||||
'type_create': True,
|
||||
'action_type': EventRuleActionChoices.WEBHOOK,
|
||||
'action_object_type': 'extras.webhook',
|
||||
'action_object_id': webhooks[5].pk,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class CustomFieldTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@@ -3,22 +3,22 @@ import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import django_rq
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
|
||||
from extras.events import enqueue_object, flush_events, serialize_for_event
|
||||
from extras.models import EventRule, Tag, Webhook
|
||||
from extras.webhooks import generate_signature
|
||||
from extras.webhooks_worker import process_webhook
|
||||
from requests import Session
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.models import Tag, Webhook
|
||||
from extras.webhooks import enqueue_object, flush_webhooks, generate_signature, serialize_for_webhook
|
||||
from extras.webhooks_worker import eval_conditions, process_webhook
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
|
||||
class WebhookTest(APITestCase):
|
||||
class EventRuleTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -35,12 +35,37 @@ class WebhookTest(APITestCase):
|
||||
DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
|
||||
|
||||
webhooks = Webhook.objects.bulk_create((
|
||||
Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
|
||||
Webhook(name='Webhook 2', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
Webhook(name='Webhook 3', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
Webhook(name='Webhook 1', payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
|
||||
Webhook(name='Webhook 2', payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
Webhook(name='Webhook 3', payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
))
|
||||
for webhook in webhooks:
|
||||
webhook.content_types.set([site_ct])
|
||||
|
||||
ct = ContentType.objects.get(app_label='extras', model='webhook')
|
||||
event_rules = EventRule.objects.bulk_create((
|
||||
EventRule(
|
||||
name='Webhook Event 1',
|
||||
type_create=True,
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=ct,
|
||||
action_object_id=webhooks[0].id
|
||||
),
|
||||
EventRule(
|
||||
name='Webhook Event 2',
|
||||
type_update=True,
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=ct,
|
||||
action_object_id=webhooks[0].id
|
||||
),
|
||||
EventRule(
|
||||
name='Webhook Event 3',
|
||||
type_delete=True,
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
action_object_type=ct,
|
||||
action_object_id=webhooks[0].id
|
||||
),
|
||||
))
|
||||
for event_rule in event_rules:
|
||||
event_rule.content_types.set([site_ct])
|
||||
|
||||
Tag.objects.bulk_create((
|
||||
Tag(name='Foo', slug='foo'),
|
||||
@@ -48,7 +73,42 @@ class WebhookTest(APITestCase):
|
||||
Tag(name='Baz', slug='baz'),
|
||||
))
|
||||
|
||||
def test_enqueue_webhook_create(self):
|
||||
def test_eventrule_conditions(self):
|
||||
"""
|
||||
Test evaluation of EventRule conditions.
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
'and': [
|
||||
{
|
||||
'attr': 'status.value',
|
||||
'value': 'active',
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_STAGING)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status='staging')
|
||||
self.assertFalse(event_rule.eval_conditions(data))
|
||||
|
||||
# Change the site's status
|
||||
site.status = SiteStatusChoices.STATUS_ACTIVE
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status='active')
|
||||
self.assertTrue(event_rule.eval_conditions(data))
|
||||
|
||||
def test_single_create_process_eventrule(self):
|
||||
"""
|
||||
Check that creating an object with an applicable EventRule queues a background task for the rule's action.
|
||||
"""
|
||||
# Create an object via the REST API
|
||||
data = {
|
||||
'name': 'Site 1',
|
||||
@@ -65,10 +125,10 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(Site.objects.count(), 1)
|
||||
self.assertEqual(Site.objects.first().tags.count(), 2)
|
||||
|
||||
# Verify that a job was queued for the object creation webhook
|
||||
# Verify that a background task was queued for the new object
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True))
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
|
||||
@@ -76,7 +136,11 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1')
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
|
||||
|
||||
def test_enqueue_webhook_bulk_create(self):
|
||||
def test_bulk_create_process_eventrule(self):
|
||||
"""
|
||||
Check that bulk creating multiple objects with an applicable EventRule queues a background task for each
|
||||
new object.
|
||||
"""
|
||||
# Create multiple objects via the REST API
|
||||
data = [
|
||||
{
|
||||
@@ -111,10 +175,10 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(Site.objects.count(), 3)
|
||||
self.assertEqual(Site.objects.first().tags.count(), 2)
|
||||
|
||||
# Verify that a webhook was queued for each object
|
||||
# Verify that a background task was queued for each new object
|
||||
self.assertEqual(self.queue.count, 3)
|
||||
for i, job in enumerate(self.queue.jobs):
|
||||
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True))
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
|
||||
@@ -122,7 +186,10 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
|
||||
|
||||
def test_enqueue_webhook_update(self):
|
||||
def test_single_update_process_eventrule(self):
|
||||
"""
|
||||
Check that updating an object with an applicable EventRule queues a background task for the rule's action.
|
||||
"""
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
|
||||
@@ -139,10 +206,10 @@ class WebhookTest(APITestCase):
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Verify that a job was queued for the object update webhook
|
||||
# Verify that a background task was queued for the updated object
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True))
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], site.pk)
|
||||
@@ -152,7 +219,11 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site X')
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
|
||||
|
||||
def test_enqueue_webhook_bulk_update(self):
|
||||
def test_bulk_update_process_eventrule(self):
|
||||
"""
|
||||
Check that bulk updating multiple objects with an applicable EventRule queues a background task for each
|
||||
updated object.
|
||||
"""
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
@@ -191,10 +262,10 @@ class WebhookTest(APITestCase):
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# Verify that a job was queued for the object update webhook
|
||||
# Verify that a background task was queued for each updated object
|
||||
self.assertEqual(self.queue.count, 3)
|
||||
for i, job in enumerate(self.queue.jobs):
|
||||
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True))
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
|
||||
@@ -204,7 +275,10 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
|
||||
self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
|
||||
|
||||
def test_enqueue_webhook_delete(self):
|
||||
def test_single_delete_process_eventrule(self):
|
||||
"""
|
||||
Check that deleting an object with an applicable EventRule queues a background task for the rule's action.
|
||||
"""
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
|
||||
@@ -214,17 +288,21 @@ class WebhookTest(APITestCase):
|
||||
response = self.client.delete(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Verify that a job was queued for the object update webhook
|
||||
# Verify that a task was queued for the deleted object
|
||||
self.assertEqual(self.queue.count, 1)
|
||||
job = self.queue.jobs[0]
|
||||
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True))
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], site.pk)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
|
||||
|
||||
def test_enqueue_webhook_bulk_delete(self):
|
||||
def test_bulk_delete_process_eventrule(self):
|
||||
"""
|
||||
Check that bulk deleting multiple objects with an applicable EventRule queues a background task for each
|
||||
deleted object.
|
||||
"""
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
@@ -243,49 +321,17 @@ class WebhookTest(APITestCase):
|
||||
response = self.client.delete(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Verify that a job was queued for the object update webhook
|
||||
# Verify that a background task was queued for each deleted object
|
||||
self.assertEqual(self.queue.count, 3)
|
||||
for i, job in enumerate(self.queue.jobs):
|
||||
self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True))
|
||||
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
|
||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||
self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
|
||||
|
||||
def test_webhook_conditions(self):
|
||||
# Create a conditional Webhook
|
||||
webhook = Webhook(
|
||||
name='Conditional Webhook',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
payload_url='http://localhost:9000/',
|
||||
conditions={
|
||||
'and': [
|
||||
{
|
||||
'attr': 'status.value',
|
||||
'value': 'active',
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_STAGING)
|
||||
data = serialize_for_webhook(site)
|
||||
|
||||
# Evaluate the conditions (status='staging')
|
||||
self.assertFalse(eval_conditions(webhook, data))
|
||||
|
||||
# Change the site's status
|
||||
site.status = SiteStatusChoices.STATUS_ACTIVE
|
||||
data = serialize_for_webhook(site)
|
||||
|
||||
# Evaluate the conditions (status='active')
|
||||
self.assertTrue(eval_conditions(webhook, data))
|
||||
|
||||
def test_webhooks_worker(self):
|
||||
|
||||
request_id = uuid.uuid4()
|
||||
|
||||
def dummy_send(_, request, **kwargs):
|
||||
@@ -293,7 +339,8 @@ class WebhookTest(APITestCase):
|
||||
A dummy implementation of Session.send() to be used for testing.
|
||||
Always returns a 200 HTTP response.
|
||||
"""
|
||||
webhook = Webhook.objects.get(type_create=True)
|
||||
event = EventRule.objects.get(type_create=True)
|
||||
webhook = event.action_object
|
||||
signature = generate_signature(request.body, webhook.secret)
|
||||
|
||||
# Validate the outgoing request headers
|
||||
@@ -322,7 +369,7 @@ class WebhookTest(APITestCase):
|
||||
request_id=request_id,
|
||||
action=ObjectChangeActionChoices.ACTION_CREATE
|
||||
)
|
||||
flush_webhooks(webhooks_queue)
|
||||
flush_events(webhooks_queue)
|
||||
|
||||
# Retrieve the job from queue
|
||||
job = self.queue.jobs[0]
|
||||
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import Provider
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from dcim.filtersets import SiteFilterSet
|
||||
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
||||
from dcim.models import Location
|
||||
@@ -159,82 +160,174 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
webhooks = (
|
||||
Webhook(
|
||||
name='Webhook 1',
|
||||
type_create=True,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?1',
|
||||
enabled=True,
|
||||
http_method='GET',
|
||||
ssl_verification=True,
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 2',
|
||||
type_create=False,
|
||||
type_update=True,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?2',
|
||||
enabled=True,
|
||||
http_method='POST',
|
||||
ssl_verification=True,
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 3',
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=True,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?3',
|
||||
enabled=False,
|
||||
http_method='PATCH',
|
||||
ssl_verification=False,
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 4',
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=True,
|
||||
type_job_end=False,
|
||||
payload_url='http://example.com/?4',
|
||||
enabled=False,
|
||||
http_method='PATCH',
|
||||
ssl_verification=False,
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 5',
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=True,
|
||||
payload_url='http://example.com/?5',
|
||||
enabled=False,
|
||||
http_method='PATCH',
|
||||
ssl_verification=False,
|
||||
),
|
||||
)
|
||||
Webhook.objects.bulk_create(webhooks)
|
||||
webhooks[0].content_types.add(content_types[0])
|
||||
webhooks[1].content_types.add(content_types[1])
|
||||
webhooks[2].content_types.add(content_types[2])
|
||||
webhooks[3].content_types.add(content_types[3])
|
||||
webhooks[4].content_types.add(content_types[4])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Webhook 1', 'Webhook 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_http_method(self):
|
||||
params = {'http_method': ['GET', 'POST']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_ssl_verification(self):
|
||||
params = {'ssl_verification': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class EventRuleTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = EventRule.objects.all()
|
||||
filterset = EventRuleFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
content_types = ContentType.objects.filter(
|
||||
model__in=['region', 'site', 'rack', 'location', 'device']
|
||||
)
|
||||
|
||||
webhooks = (
|
||||
Webhook(
|
||||
name='Webhook 1',
|
||||
payload_url='http://example.com/?1',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 2',
|
||||
payload_url='http://example.com/?2',
|
||||
),
|
||||
Webhook(
|
||||
name='Webhook 3',
|
||||
payload_url='http://example.com/?3',
|
||||
),
|
||||
)
|
||||
Webhook.objects.bulk_create(webhooks)
|
||||
|
||||
scripts = (
|
||||
ScriptModule(
|
||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||
file_path='/var/tmp/script1.py'
|
||||
),
|
||||
ScriptModule(
|
||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||
file_path='/var/tmp/script2.py'
|
||||
),
|
||||
)
|
||||
ScriptModule.objects.bulk_create(scripts)
|
||||
|
||||
event_rules = (
|
||||
EventRule(
|
||||
name='Event Rule 1',
|
||||
action_object=webhooks[0],
|
||||
enabled=True,
|
||||
type_create=True,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
),
|
||||
EventRule(
|
||||
name='Event Rule 2',
|
||||
action_object=webhooks[1],
|
||||
enabled=True,
|
||||
type_create=False,
|
||||
type_update=True,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
),
|
||||
EventRule(
|
||||
name='Event Rule 3',
|
||||
action_object=webhooks[2],
|
||||
enabled=False,
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=True,
|
||||
type_job_start=False,
|
||||
type_job_end=False,
|
||||
action_type=EventRuleActionChoices.WEBHOOK,
|
||||
),
|
||||
EventRule(
|
||||
name='Event Rule 4',
|
||||
action_object=scripts[0],
|
||||
enabled=False,
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=True,
|
||||
type_job_end=False,
|
||||
action_type=EventRuleActionChoices.SCRIPT,
|
||||
),
|
||||
EventRule(
|
||||
name='Event Rule 5',
|
||||
action_object=scripts[1],
|
||||
enabled=False,
|
||||
type_create=False,
|
||||
type_update=False,
|
||||
type_delete=False,
|
||||
type_job_start=False,
|
||||
type_job_end=True,
|
||||
action_type=EventRuleActionChoices.SCRIPT,
|
||||
),
|
||||
)
|
||||
EventRule.objects.bulk_create(event_rules)
|
||||
event_rules[0].content_types.add(content_types[0])
|
||||
event_rules[1].content_types.add(content_types[1])
|
||||
event_rules[2].content_types.add(content_types[2])
|
||||
event_rules[3].content_types.add(content_types[3])
|
||||
event_rules[4].content_types.add(content_types[4])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Event Rule 1', 'Event Rule 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_types(self):
|
||||
params = {'content_types': 'dcim.region'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_action_type(self):
|
||||
params = {'action_type': [EventRuleActionChoices.WEBHOOK]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'action_type': [EventRuleActionChoices.SCRIPT]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': False}
|
||||
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)
|
||||
@@ -255,18 +348,6 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'type_job_end': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_http_method(self):
|
||||
params = {'http_method': ['GET', 'POST']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_ssl_verification(self):
|
||||
params = {'ssl_verification': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class CustomLinkTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = CustomLink.objects.all()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
@@ -11,7 +10,6 @@ from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@@ -336,33 +334,26 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
webhooks = (
|
||||
Webhook(name='Webhook 1', payload_url='http://example.com/?1', type_create=True, http_method='POST'),
|
||||
Webhook(name='Webhook 2', payload_url='http://example.com/?2', type_create=True, http_method='POST'),
|
||||
Webhook(name='Webhook 3', payload_url='http://example.com/?3', type_create=True, http_method='POST'),
|
||||
Webhook(name='Webhook 1', payload_url='http://example.com/?1', http_method='POST'),
|
||||
Webhook(name='Webhook 2', payload_url='http://example.com/?2', http_method='POST'),
|
||||
Webhook(name='Webhook 3', payload_url='http://example.com/?3', http_method='POST'),
|
||||
)
|
||||
for webhook in webhooks:
|
||||
webhook.save()
|
||||
webhook.content_types.add(site_ct)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Webhook X',
|
||||
'content_types': [site_ct.pk],
|
||||
'type_create': False,
|
||||
'type_update': True,
|
||||
'type_delete': True,
|
||||
'payload_url': 'http://example.com/?x',
|
||||
'http_method': 'GET',
|
||||
'http_content_type': 'application/foo',
|
||||
'conditions': None,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,content_types,type_create,payload_url,http_method,http_content_type",
|
||||
"Webhook 4,dcim.site,True,http://example.com/?4,GET,application/json",
|
||||
"Webhook 5,dcim.site,True,http://example.com/?5,GET,application/json",
|
||||
"Webhook 6,dcim.site,True,http://example.com/?6,GET,application/json",
|
||||
"name,payload_url,http_method,http_content_type",
|
||||
"Webhook 4,http://example.com/?4,GET,application/json",
|
||||
"Webhook 5,http://example.com/?5,GET,application/json",
|
||||
"Webhook 6,http://example.com/?6,GET,application/json",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -373,11 +364,62 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'enabled': False,
|
||||
'http_method': 'GET',
|
||||
}
|
||||
|
||||
|
||||
class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = EventRule
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
webhooks = (
|
||||
Webhook(name='Webhook 1', payload_url='http://example.com/?1', http_method='POST'),
|
||||
Webhook(name='Webhook 2', payload_url='http://example.com/?2', http_method='POST'),
|
||||
Webhook(name='Webhook 3', payload_url='http://example.com/?3', http_method='POST'),
|
||||
)
|
||||
for webhook in webhooks:
|
||||
webhook.save()
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
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]),
|
||||
)
|
||||
for event in event_rules:
|
||||
event.save()
|
||||
event.content_types.add(site_ct)
|
||||
|
||||
webhook_ct = ContentType.objects.get_for_model(Webhook)
|
||||
cls.form_data = {
|
||||
'name': 'Event X',
|
||||
'content_types': [site_ct.pk],
|
||||
'type_create': False,
|
||||
'type_update': True,
|
||||
'type_delete': True,
|
||||
'http_method': 'GET',
|
||||
'conditions': None,
|
||||
'action_type': 'webhook',
|
||||
'action_object_type': webhook_ct.pk,
|
||||
'action_object_id': webhooks[0].pk,
|
||||
'action_choice': webhooks[0]
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,content_types,type_create,action_type,action_object",
|
||||
"Webhook 4,dcim.site,True,webhook,Webhook 1",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name",
|
||||
f"{event_rules[0].pk},Event 7",
|
||||
f"{event_rules[1].pk},Event 8",
|
||||
f"{event_rules[2].pk},Event 9",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'type_update': True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,14 @@ urlpatterns = [
|
||||
path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'),
|
||||
path('webhooks/<int:pk>/', include(get_model_urls('extras', 'webhook'))),
|
||||
|
||||
# Event rules
|
||||
path('event-rules/', views.EventRuleListView.as_view(), name='eventrule_list'),
|
||||
path('event-rules/add/', views.EventRuleEditView.as_view(), name='eventrule_add'),
|
||||
path('event-rules/import/', views.EventRuleBulkImportView.as_view(), name='eventrule_import'),
|
||||
path('event-rules/edit/', views.EventRuleBulkEditView.as_view(), name='eventrule_bulk_edit'),
|
||||
path('event-rules/delete/', views.EventRuleBulkDeleteView.as_view(), name='eventrule_bulk_delete'),
|
||||
path('event-rules/<int:pk>/', include(get_model_urls('extras', 'eventrule'))),
|
||||
|
||||
# Tags
|
||||
path('tags/', views.TagListView.as_view(), name='tag_list'),
|
||||
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
|
||||
|
||||
@@ -395,6 +395,51 @@ class WebhookBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.WebhookTable
|
||||
|
||||
|
||||
#
|
||||
# Event Rules
|
||||
#
|
||||
|
||||
class EventRuleListView(generic.ObjectListView):
|
||||
queryset = EventRule.objects.all()
|
||||
filterset = filtersets.EventRuleFilterSet
|
||||
filterset_form = forms.EventRuleFilterForm
|
||||
table = tables.EventRuleTable
|
||||
|
||||
|
||||
@register_model_view(EventRule)
|
||||
class EventRuleView(generic.ObjectView):
|
||||
queryset = EventRule.objects.all()
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'edit')
|
||||
class EventRuleEditView(generic.ObjectEditView):
|
||||
queryset = EventRule.objects.all()
|
||||
form = forms.EventRuleForm
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'delete')
|
||||
class EventRuleDeleteView(generic.ObjectDeleteView):
|
||||
queryset = EventRule.objects.all()
|
||||
|
||||
|
||||
class EventRuleBulkImportView(generic.BulkImportView):
|
||||
queryset = EventRule.objects.all()
|
||||
model_form = forms.EventRuleImportForm
|
||||
|
||||
|
||||
class EventRuleBulkEditView(generic.BulkEditView):
|
||||
queryset = EventRule.objects.all()
|
||||
filterset = filtersets.EventRuleFilterSet
|
||||
table = tables.EventRuleTable
|
||||
form = forms.EventRuleBulkEditForm
|
||||
|
||||
|
||||
class EventRuleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = EventRule.objects.all()
|
||||
filterset = filtersets.EventRuleFilterSet
|
||||
table = tables.EventRuleTable
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
@@ -1,47 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from django_rq import get_queue
|
||||
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.registry import registry
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.rqworker import get_rq_retry
|
||||
from utilities.utils import serialize_object
|
||||
from .choices import *
|
||||
from .models import Webhook
|
||||
|
||||
|
||||
def serialize_for_webhook(instance):
|
||||
"""
|
||||
Return a serialized representation of the given instance suitable for use in a webhook.
|
||||
"""
|
||||
serializer_class = get_serializer_for_model(instance.__class__)
|
||||
serializer_context = {
|
||||
'request': None,
|
||||
}
|
||||
serializer = serializer_class(instance, context=serializer_context)
|
||||
|
||||
return serializer.data
|
||||
|
||||
|
||||
def get_snapshots(instance, action):
|
||||
snapshots = {
|
||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||
'postchange': None,
|
||||
}
|
||||
if action != ObjectChangeActionChoices.ACTION_DELETE:
|
||||
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
|
||||
if hasattr(instance, 'serialize_object'):
|
||||
snapshots['postchange'] = instance.serialize_object()
|
||||
else:
|
||||
snapshots['postchange'] = serialize_object(instance)
|
||||
|
||||
return snapshots
|
||||
|
||||
|
||||
def generate_signature(request_body, secret):
|
||||
"""
|
||||
@@ -53,70 +12,3 @@ def generate_signature(request_body, secret):
|
||||
digestmod=hashlib.sha512
|
||||
)
|
||||
return hmac_prep.hexdigest()
|
||||
|
||||
|
||||
def enqueue_object(queue, instance, user, request_id, action):
|
||||
"""
|
||||
Enqueue a serialized representation of a created/updated/deleted object for the processing of
|
||||
webhooks once the request has completed.
|
||||
"""
|
||||
# Determine whether this type of object supports webhooks
|
||||
app_label = instance._meta.app_label
|
||||
model_name = instance._meta.model_name
|
||||
if model_name not in registry['model_features']['webhooks'].get(app_label, []):
|
||||
return
|
||||
|
||||
queue.append({
|
||||
'content_type': ContentType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event': action,
|
||||
'data': serialize_for_webhook(instance),
|
||||
'snapshots': get_snapshots(instance, action),
|
||||
'username': user.username,
|
||||
'request_id': request_id
|
||||
})
|
||||
|
||||
|
||||
def flush_webhooks(queue):
|
||||
"""
|
||||
Flush a list of object representation to RQ for webhook processing.
|
||||
"""
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
|
||||
rq_queue = get_queue(rq_queue_name)
|
||||
webhooks_cache = {
|
||||
'type_create': {},
|
||||
'type_update': {},
|
||||
'type_delete': {},
|
||||
}
|
||||
|
||||
for data in queue:
|
||||
|
||||
action_flag = {
|
||||
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
||||
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
||||
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
||||
}[data['event']]
|
||||
content_type = data['content_type']
|
||||
|
||||
# Cache applicable Webhooks
|
||||
if content_type not in webhooks_cache[action_flag]:
|
||||
webhooks_cache[action_flag][content_type] = Webhook.objects.filter(
|
||||
**{action_flag: True},
|
||||
content_types=content_type,
|
||||
enabled=True
|
||||
)
|
||||
webhooks = webhooks_cache[action_flag][content_type]
|
||||
|
||||
for webhook in webhooks:
|
||||
rq_queue.enqueue(
|
||||
"extras.webhooks_worker.process_webhook",
|
||||
webhook=webhook,
|
||||
model_name=content_type.model,
|
||||
event=data['event'],
|
||||
data=data['data'],
|
||||
snapshots=data['snapshots'],
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=data['username'],
|
||||
request_id=data['request_id'],
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
||||
@@ -5,36 +5,18 @@ from django.conf import settings
|
||||
from django_rq import job
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from .conditions import ConditionSet
|
||||
from .constants import WEBHOOK_EVENT_TYPES
|
||||
from .webhooks import generate_signature
|
||||
|
||||
logger = logging.getLogger('netbox.webhooks_worker')
|
||||
|
||||
|
||||
def eval_conditions(webhook, data):
|
||||
"""
|
||||
Test whether the given data meets the conditions of the webhook (if any). Return True
|
||||
if met or no conditions are specified.
|
||||
"""
|
||||
if not webhook.conditions:
|
||||
return True
|
||||
|
||||
logger.debug(f'Evaluating webhook conditions: {webhook.conditions}')
|
||||
if ConditionSet(webhook.conditions).eval(data):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@job('default')
|
||||
def process_webhook(webhook, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
|
||||
def process_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
# Evaluate webhook conditions (if any)
|
||||
if not eval_conditions(webhook, data):
|
||||
return
|
||||
webhook = event_rule.action_object
|
||||
|
||||
# Prepare context data for headers & body templates
|
||||
context = {
|
||||
|
||||
@@ -2,9 +2,9 @@ from contextvars import ContextVar
|
||||
|
||||
__all__ = (
|
||||
'current_request',
|
||||
'webhooks_queue',
|
||||
'events_queue',
|
||||
)
|
||||
|
||||
|
||||
current_request = ContextVar('current_request', default=None)
|
||||
webhooks_queue = ContextVar('webhooks_queue', default=[])
|
||||
events_queue = ContextVar('events_queue', default=[])
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.db import connection, ProgrammingError
|
||||
from django.db.utils import InternalError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
from extras.context_managers import change_logging
|
||||
from extras.context_managers import event_tracking
|
||||
from netbox.config import clear_config, get_config
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request, rest_api_server_error
|
||||
@@ -42,8 +42,8 @@ class CoreMiddleware:
|
||||
login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}'
|
||||
return HttpResponseRedirect(login_url)
|
||||
|
||||
# Enable the change_logging context manager and process the request.
|
||||
with change_logging(request):
|
||||
# Enable the event_tracking context manager and process the request.
|
||||
with event_tracking(request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Attach the unique request ID as an HTTP header.
|
||||
|
||||
@@ -30,7 +30,7 @@ class NetBoxFeatureSet(
|
||||
ExportTemplatesMixin,
|
||||
JournalingMixin,
|
||||
TagsMixin,
|
||||
WebhooksMixin
|
||||
EventRulesMixin
|
||||
):
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -44,7 +44,7 @@ class NetBoxFeatureSet(
|
||||
# Base model classes
|
||||
#
|
||||
|
||||
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, WebhooksMixin, models.Model):
|
||||
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, EventRulesMixin, models.Model):
|
||||
"""
|
||||
Base model for ancillary models; provides limited functionality for models which don't
|
||||
support NetBox's full feature set.
|
||||
|
||||
@@ -35,7 +35,7 @@ __all__ = (
|
||||
'JournalingMixin',
|
||||
'SyncedDataMixin',
|
||||
'TagsMixin',
|
||||
'WebhooksMixin',
|
||||
'EventRulesMixin',
|
||||
)
|
||||
|
||||
|
||||
@@ -400,9 +400,9 @@ class TagsMixin(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class WebhooksMixin(models.Model):
|
||||
class EventRulesMixin(models.Model):
|
||||
"""
|
||||
Enables support for webhooks.
|
||||
Enables support for event rules, which can be used to transmit webhooks or execute scripts automatically.
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -555,7 +555,7 @@ FEATURES_MAP = {
|
||||
'journaling': JournalingMixin,
|
||||
'synced_data': SyncedDataMixin,
|
||||
'tags': TagsMixin,
|
||||
'webhooks': WebhooksMixin,
|
||||
'event_rules': EventRulesMixin,
|
||||
}
|
||||
|
||||
registry['model_features'].update({
|
||||
|
||||
@@ -343,6 +343,7 @@ OPERATIONS_MENU = Menu(
|
||||
label=_('Integrations'),
|
||||
items=(
|
||||
get_model_item('core', 'datasource', _('Data Sources')),
|
||||
get_model_item('extras', 'eventrule', _('Event Rules')),
|
||||
get_model_item('extras', 'webhook', _('Webhooks')),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -115,6 +115,9 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
|
||||
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
|
||||
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
|
||||
'extras.events.process_event_queue',
|
||||
))
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
@@ -672,7 +675,7 @@ GRAPHENE = {
|
||||
|
||||
|
||||
#
|
||||
# Django RQ (Webhooks backend)
|
||||
# Django RQ (events backend)
|
||||
#
|
||||
|
||||
if TASKS_REDIS_USING_SENTINEL:
|
||||
|
||||
@@ -17,7 +17,7 @@ from django.utils.safestring import mark_safe
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from extras.signals import clear_webhooks
|
||||
from extras.signals import clear_events
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||
@@ -279,7 +279,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -474,12 +474,12 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
return redirect(results_url)
|
||||
|
||||
except (AbortTransaction, ValidationError):
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -632,12 +632,12 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
except ValidationError as e:
|
||||
messages.error(self.request, ", ".join(e.messages))
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -733,7 +733,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
else:
|
||||
form = self.form(initial={'pk': request.POST.getlist('pk')})
|
||||
@@ -927,12 +927,12 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
raise PermissionsViolation
|
||||
|
||||
except IntegrityError:
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
if not form.errors:
|
||||
msg = "Added {} {} to {} {}.".format(
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from extras.signals import clear_webhooks
|
||||
from extras.signals import clear_events
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, PermissionsViolation
|
||||
from utilities.forms import ConfirmationForm, restrict_form_fields
|
||||
@@ -300,7 +300,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -528,7 +528,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
clear_webhooks.send(sender=self)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'object': instance,
|
||||
|
||||
98
netbox/templates/extras/eventrule.html
Normal file
98
netbox/templates/extras/eventrule.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Event Rule" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Events" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Object Types" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.content_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Conditions" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% if object.conditions %}
|
||||
<pre>{{ object.conditions|json }}</pre>
|
||||
{% else %}
|
||||
<p class="text-muted">{% trans "None" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -16,39 +16,6 @@
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Events" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,32 +64,6 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Assigned Models" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.content_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Conditions" %}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% if object.conditions %}
|
||||
<pre>{{ object.conditions|json }}</pre>
|
||||
{% else %}
|
||||
<p class="text-muted">{% trans "None" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{% trans "Additional Headers" %}
|
||||
|
||||
@@ -103,7 +103,7 @@ class JSONField(_JSONField):
|
||||
def prepare_value(self, value):
|
||||
if isinstance(value, InvalidJSONInput):
|
||||
return value
|
||||
if value is None:
|
||||
if value in ('', None):
|
||||
return ''
|
||||
return json.dumps(value, sort_keys=True, indent=4)
|
||||
|
||||
|
||||
@@ -128,10 +128,9 @@ def get_field_value(form, field_name):
|
||||
"""
|
||||
field = form.fields[field_name]
|
||||
|
||||
if form.is_bound:
|
||||
if data := form.data.get(field_name):
|
||||
if field.valid_value(data):
|
||||
return data
|
||||
if form.is_bound and (data := form.data.get(field_name)):
|
||||
if hasattr(field, 'valid_value') and field.valid_value(data):
|
||||
return data
|
||||
|
||||
return form.get_initial_for_field(field, field_name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user