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:
Arthur Hanson 2023-11-30 13:36:33 -08:00 committed by GitHub
parent b83fcc6077
commit a38a38218b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1569 additions and 589 deletions

View File

@ -59,10 +59,7 @@ DATABASE = {
## REDIS
[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching
functionality (as well as other planned features). In 2.7, the connection settings were broken down into two sections for
task queuing and caching, allowing the user to connect to different Redis instances/databases per feature.
[Redis](https://redis.io/) is a lightweight in-memory data store similar to memcached. NetBox employs Redis for background task queuing and other features.
Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `tasks` and `caching` subsections:
@ -81,7 +78,7 @@ REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'USERNAME': 'netbox'
'USERNAME': 'netbox',
'PASSWORD': 'foobar',
'DATABASE': 0,
'SSL': False,
@ -89,7 +86,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': ''
'USERNAME': '',
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,

View File

@ -31,7 +31,7 @@ A dictionary of particular features (e.g. custom fields) mapped to the NetBox mo
'dcim': ['site', 'rack', 'devicetype', ...],
...
},
'webhooks': {
'event_rules': {
'extras': ['configcontext', 'tag', ...],
'dcim': ['site', 'rack', 'devicetype', ...],
},

View File

@ -10,19 +10,19 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
Depending on its classification, each NetBox model may support various features which enhance its operation. Each feature is enabled by inheriting from its designated mixin class, and some features also make use of the [application registry](./application-registry.md#model_features).
| Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|--------------------|--------------------------------------------------------------------------------|
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | - | Changes to these objects are automatically recorded in the change log |
| Cloning | `CloningMixin` | - | Provides the `clone()` method to prepare a copy |
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
| [Job results](../features/background-jobs.md) | `JobsMixin` | `jobs` | Users can create custom export templates for these models |
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
| [Webhooks](../integrations/webhooks.md) | `WebhooksMixin` | `webhooks` | NetBox is capable of generating outgoing webhooks for these objects |
| Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|--------------------|-----------------------------------------------------------------------------------------|
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | - | Changes to these objects are automatically recorded in the change log |
| Cloning | `CloningMixin` | - | Provides the `clone()` method to prepare a copy |
| [Custom fields](../customization/custom-fields.md) | `CustomFieldsMixin` | `custom_fields` | These models support the addition of user-defined fields |
| [Custom links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
| [Job results](../features/background-jobs.md) | `JobsMixin` | `jobs` | Users can create custom export templates for these models |
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
| [Event rules](../features/event-rules.md) | `EventRulesMixin` | `event_rules` | Event rules can send webhooks or run custom scripts automatically in response to events |
## Models Index
@ -111,7 +111,7 @@ Component models represent individual physical or virtual components belonging t
### Component Template Models
These function as templates to effect the replication of device and virtual machine components. Component template models support a limited feature set, including change logging, custom validation, and webhooks.
These function as templates to effect the replication of device and virtual machine components. Component template models support a limited feature set, including change logging, custom validation, and event rules.
* [dcim.ConsolePortTemplate](../models/dcim/consoleporttemplate.md)
* [dcim.ConsoleServerPortTemplate](../models/dcim/consoleserverporttemplate.md)

View File

@ -26,9 +26,9 @@ To learn more about this feature, check out the [GraphQL API documentation](../i
## Webhooks
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are an excellent mechanism for building event-based automation processes.
A webhook is a mechanism for conveying to some external system a change that has taken place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. To do this, first create a [webhook](../models/extras/webhook.md) identifying the remote receiver (URL), HTTP method, and any other necessary parameters. Then, define an [event rule](../models/extras/eventrule.md) which is triggered by device changes to transmit the webhook.
To learn more about this feature, check out the [webhooks documentation](../integrations/webhooks.md).
When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are an excellent mechanism for building event-based automation processes. To learn more about this feature, check out the [webhooks documentation](../integrations/webhooks.md).
## Prometheus Metrics

View File

@ -0,0 +1,31 @@
# Event Rules
NetBox includes the ability to execute certain functions in response to internal object changes. These include:
* [Scripts](../customization/custom-scripts.md) execution
* [Webhooks](../integrations/webhooks.md) execution
For example, suppose you want to automatically configure a monitoring system to start monitoring a device when its operational status is changed to active, and remove it from monitoring for any other status. You can create a webhook in NetBox for the device model and craft its content and destination URL to effect the desired change on the receiving system. You can then associate an event rule with this webhook and the webhook will be sent automatically by NetBox whenever the configured constraints are met.
Each event must be associated with at least one NetBox object type and at least one event (e.g. create, update, or delete).
## Conditional Event Rules
An event rule may include a set of conditional logic expressed in JSON used to control whether an event triggers for a specific object. For example, you may wish to trigger an event for devices only when the `status` field of an object is "active":
```json
{
"and": [
{
"attr": "status.value",
"value": "active"
}
]
}
```
For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).
## Event Rule Processing
When a change is detected, any resulting events are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing event(s) to be processed. The events are then extracted from the queue by the `rqworker` process. The current event queue and any failed events can be inspected in the admin UI under System > Background Tasks.

View File

@ -32,7 +32,7 @@ In addition to its expansive and robust data model, NetBox offers myriad mechani
* Custom fields
* Custom model validation
* Export templates
* Webhooks
* Event rules
* Plugins
* REST & GraphQL APIs

View File

@ -1,11 +1,9 @@
# Webhooks
NetBox can be configured to transmit outgoing webhooks to remote systems in response to internal object changes. The receiver can act on the data in these webhook messages to perform related tasks.
NetBox can be configured via [Event Rules](../features/event-rules.md) to transmit outgoing webhooks to remote systems in response to internal object changes. The receiver can act on the data in these webhook messages to perform related tasks.
For example, suppose you want to automatically configure a monitoring system to start monitoring a device when its operational status is changed to active, and remove it from monitoring for any other status. You can create a webhook in NetBox for the device model and craft its content and destination URL to effect the desired change on the receiving system. Webhooks will be sent automatically by NetBox whenever the configured constraints are met.
Each webhook must be associated with at least one NetBox object type and at least one event (create, update, or delete). Users can specify the receiver URL, HTTP request type (`GET`, `POST`, etc.), content type, and headers. A request body can also be specified; if left blank, this will default to a serialized representation of the affected object.
!!! warning "Security Notice"
Webhooks support the inclusion of user-submitted code to generate the URL, custom headers, and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
@ -70,26 +68,12 @@ If no body template is specified, the request body will be populated with a JSON
}
```
## Conditional Webhooks
A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active":
```json
{
"and": [
{
"attr": "status.value",
"value": "active"
}
]
}
```
For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md).
!!! note
The setting of conditional webhooks has been moved to [Event Rules](../features/event-rules.md) since NetBox 3.7
## Webhook Processing
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks.
Using [Event Rules](../features/event-rules.md), when a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks.
A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI.

View File

@ -0,0 +1,35 @@
# EventRule
An event rule is a mechanism for automatically taking an action (such as running a script or sending a webhook) in response to an event in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating an event for device objects and designating a webhook to be transmitted. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver.
See the [event rules documentation](../features/event-rules.md) for more information.
## Fields
### Name
A unique human-friendly name.
### Content Types
The type(s) of object in NetBox that will trigger the rule.
### Enabled
If not selected, the event rule will not be processed.
### Events
The events which will trigger the rule. At least one event type must be selected.
| Name | Description |
|------------|--------------------------------------|
| Creations | A new object has been created |
| Updates | An existing object has been modified |
| Deletions | An object has been deleted |
| Job starts | A job for an object starts |
| Job ends | A job for an object terminates |
### Conditions
A set of [prescribed conditions](../../reference/conditions.md) against which the triggering object will be evaluated. If the conditions are defined but not met by the object, no action will be taken. An event rule that does not define any conditions will _always_ trigger.

View File

@ -123,14 +123,17 @@ For more information about database migrations, see the [Django documentation](h
::: netbox.models.features.CustomValidationMixin
::: netbox.models.features.EventRulesMixin
!!! note
`EventRulesMixin` was renamed from `WebhooksMixin` in NetBox v3.7.
::: netbox.models.features.ExportTemplatesMixin
::: netbox.models.features.JournalingMixin
::: netbox.models.features.TagsMixin
::: netbox.models.features.WebhooksMixin
## Choice Sets
For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)

View File

@ -87,6 +87,7 @@ nav:
- Auth & Permissions: 'features/authentication-permissions.md'
- API & Integration: 'features/api-integration.md'
- Customization: 'features/customization.md'
- Event Rules: 'features/event-rules.md'
- Installation & Upgrade:
- Installing NetBox: 'installation/index.md'
- 1. PostgreSQL: 'installation/1-postgresql.md'
@ -215,6 +216,7 @@ nav:
- CustomField: 'models/extras/customfield.md'
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
- CustomLink: 'models/extras/customlink.md'
- EventRule: 'models/extras/eventrule.md'
- ExportTemplate: 'models/extras/exporttemplate.md'
- ImageAttachment: 'models/extras/imageattachment.md'
- JournalEntry: 'models/extras/journalentry.md'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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([])
#

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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=[])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 %}

View File

@ -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" %}

View File

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

View File

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