Merge branch 'feature' into 10054-ui-object-selectors

This commit is contained in:
jeremystretch 2023-03-10 14:53:44 -05:00
commit e04c62ada3
25 changed files with 338 additions and 106 deletions

View File

@ -22,11 +22,13 @@ If not selected, the webhook will be inactive.
The events which will trigger the webhook. 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 |
| 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 |
### URL
@ -58,6 +60,10 @@ Jinja2 template for a custom request body, if desired. If not defined, NetBox wi
A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
### 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, the webhook will not be sent. A webhook that does not define any conditions will _always_ trigger.
### SSL Verification
Controls whether validation of the receiver's SSL certificate is enforced when HTTPS is used.

View File

@ -27,6 +27,8 @@ A new ASN range model has been introduced to facilitate the provisioning of new
### Enhancements
* [#7947](https://github.com/netbox-community/netbox/issues/7947) - Enable marking IP ranges as fully utilized
* [#8272](https://github.com/netbox-community/netbox/issues/8272) - Support bridge relationships among device type interfaces
* [#8958](https://github.com/netbox-community/netbox/issues/8958) - Changes in background job status can trigger webhooks
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
* [#9653](https://github.com/netbox-community/netbox/issues/9653) - Enable setting a default platform for device types
* [#10374](https://github.com/netbox-community/netbox/issues/10374) - Require unique tenant names & slugs per group (not globally)

View File

@ -22,8 +22,9 @@ def sync_datasource(job_result, *args, **kwargs):
# Update the search cache for DataFiles belonging to this source
search_backend.cache(datasource.datafiles.iterator())
job_result.terminate()
except SyncError as e:
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
logging.error(e)

View File

@ -475,6 +475,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
bridge = NestedInterfaceTemplateSerializer(required=False, allow_null=True)
poe_mode = ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
@ -489,7 +490,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'bridge', 'enabled', 'mgmt_only', 'description',
'poe_mode', 'poe_type', 'created', 'last_updated',
]

View File

@ -810,15 +810,24 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
class InterfaceTemplateForm(ModularComponentTemplateForm):
bridge = DynamicModelChoiceField(
queryset=InterfaceTemplate.objects.all(),
required=False,
query_params={
'devicetype_id': '$device_type',
'moduletype_id': '$module_type',
}
)
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')),
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')),
('PoE', ('poe_mode', 'poe_type'))
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', 'bridge',
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.1.6 on 2023-03-01 13:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0170_configtemplate'),
]
operations = [
migrations.AddField(
model_name='interfacetemplate',
name='bridge',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interfacetemplate'),
),
]

View File

@ -350,6 +350,14 @@ class InterfaceTemplate(ModularComponentTemplateModel):
default=False,
verbose_name='Management only'
)
bridge = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
related_name='bridge_interfaces',
null=True,
blank=True,
verbose_name='Bridge interface'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
@ -365,6 +373,19 @@ class InterfaceTemplate(ModularComponentTemplateModel):
component_model = Interface
def clean(self):
super().clean()
if self.bridge:
if self.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({
'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type"
})
if self.module_type and self.module_type != self.bridge.module_type:
raise ValidationError({
'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type"
})
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@ -385,6 +406,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
'bridge': self.bridge.name if self.bridge else None,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,
}

View File

@ -460,6 +460,20 @@ class Platform(OrganizationalModel):
return reverse('dcim:platform', args=[self.pk])
def update_interface_bridges(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
and applies it to the actual interfaces.
"""
for interface_template in interface_templates.exclude(bridge=None):
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
if interface_template.bridge:
interface.bridge = Interface.objects.get(device=device, name=interface_template.bridge.resolve_name(module=module))
interface.full_clean()
interface.save()
class Device(PrimaryModel, ConfigContextModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@ -854,6 +868,8 @@ class Device(PrimaryModel, ConfigContextModel):
self._instantiate_components(self.device_type.devicebaytemplates.all())
# Disable bulk_create to accommodate MPTT
self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
# Interface bridges have to be set after interface instantiation
update_interface_bridges(self, self.device_type.interfacetemplates.all())
# Update Site and Rack assignment for any child Devices
devices = Device.objects.filter(parent_bay__device=self)
@ -1090,6 +1106,9 @@ class Module(PrimaryModel, ConfigContextModel):
update_fields=update_fields
)
# Interface bridges have to be set after interface instantiation
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
#
# Virtual chassis

View File

@ -187,7 +187,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = models.InterfaceTemplate
fields = ('pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
fields = ('pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type', 'actions')
empty_text = "None"

View File

@ -68,9 +68,10 @@ class WebhookSerializer(ValidatedModelSerializer):
class Meta:
model = Webhook
fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
'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',
'created', 'last_updated',
]

View File

@ -1,8 +1,16 @@
from django.contrib.contenttypes.models import ContentType
# Webhook content types
# Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json'
WEBHOOK_EVENT_TYPES = {
'create': 'created',
'update': 'updated',
'delete': 'deleted',
'job_start': 'job_started',
'job_end': 'job_ended',
}
# Dashboard
DEFAULT_DASHBOARD = [
{

View File

@ -48,8 +48,8 @@ class WebhookFilterSet(BaseFilterSet):
class Meta:
model = Webhook
fields = [
'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method',
'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
'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',
]
def search(self, queryset, name, value):

View File

@ -140,6 +140,14 @@ class WebhookBulkEditForm(BulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
type_job_start = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_job_end = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
http_method = forms.ChoiceField(
choices=add_blank_choice(WebhookHttpMethodChoices),
required=False,

View File

@ -116,9 +116,9 @@ class WebhookImportForm(CSVModelForm):
class Meta:
model = Webhook
fields = (
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
'ca_file_path'
'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',
'secret', 'ssl_verification', 'ca_file_path'
)

View File

@ -222,7 +222,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', ('content_type_id', 'http_method', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
('Events', ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
@ -244,19 +244,36 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
),
label=_('Object creations')
)
type_update = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
),
label=_('Object updates')
)
type_delete = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
),
label=_('Object deletions')
)
type_job_start = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Job starts')
)
type_job_end = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Job terminations')
)

View File

@ -154,7 +154,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
fieldsets = (
('Webhook', ('name', 'content_types', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
('Events', ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)),
@ -169,6 +169,8 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
'type_job_start': 'Job executions',
'type_job_end': 'Job terminations',
}
widgets = {
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),

View File

@ -41,16 +41,16 @@ class Command(BaseCommand):
the change_logging context manager (which is bypassed if commit == False).
"""
try:
with transaction.atomic():
script.output = script.run(data=data, commit=commit)
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
if not commit:
raise AbortTransaction()
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
try:
with transaction.atomic():
script.output = script.run(data=data, commit=commit)
if not commit:
raise AbortTransaction()
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
job_result.data = ScriptOutputSerializer(script).data
job_result.terminate()
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
@ -58,11 +58,9 @@ class Command(BaseCommand):
)
script.log_info("Database changes have been reverted due to error.")
logger.error(f"Exception raised during script execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
finally:
job_result.data = ScriptOutputSerializer(script).data
job_result.save()
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
logger.info(f"Script completed in {job_result.duration}")

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.7 on 2023-02-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0087_dashboard'),
]
operations = [
migrations.AddField(
model_name='webhook',
name='type_job_end',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='webhook',
name='type_job_start',
field=models.BooleanField(default=False),
),
]

View File

@ -5,7 +5,6 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.validators import MinValueValidator, ValidationError
from django.db import models
@ -27,7 +26,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
TagsMixin,
TagsMixin, WebhooksMixin,
)
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
@ -65,15 +64,23 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
)
type_create = models.BooleanField(
default=False,
help_text=_("Call this webhook when a matching object is created.")
help_text=_("Triggers when a matching object is created.")
)
type_update = models.BooleanField(
default=False,
help_text=_("Call this webhook when a matching object is updated.")
help_text=_("Triggers when a matching object is updated.")
)
type_delete = models.BooleanField(
default=False,
help_text=_("Call this webhook when a matching object is deleted.")
help_text=_("Triggers when a matching object is deleted.")
)
type_job_start = models.BooleanField(
default=False,
help_text=_("Triggers when a job for a matching object is started.")
)
type_job_end = models.BooleanField(
default=False,
help_text=_("Triggers when a job for a matching object terminates.")
)
payload_url = models.CharField(
max_length=500,
@ -159,8 +166,12 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
super().clean()
# At least one action type must be selected
if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError("At least one type must be selected: create, update, and/or delete.")
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:
@ -678,19 +689,32 @@ class JobResult(models.Model):
"""
Record the job's start time and update its status to "running."
"""
if self.started is None:
self.started = timezone.now()
self.status = JobResultStatusChoices.STATUS_RUNNING
JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
if self.started is not None:
return
def set_status(self, status):
# Start the job
self.started = timezone.now()
self.status = JobResultStatusChoices.STATUS_RUNNING
JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
# Handle webhooks
self.trigger_webhooks(event='job_start')
def terminate(self, status=JobResultStatusChoices.STATUS_COMPLETED):
"""
Helper method to change the status of the job result. If the target status is terminal, the completion
time is also set.
Mark the job as completed, optionally specifying a particular termination status.
"""
valid_statuses = JobResultStatusChoices.TERMINAL_STATE_CHOICES
if status not in valid_statuses:
raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
# Mark the job as completed
self.status = status
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
self.completed = timezone.now()
self.completed = timezone.now()
JobResult.objects.filter(pk=self.pk).update(status=self.status, completed=self.completed)
# Handle webhooks
self.trigger_webhooks(event='job_end')
@classmethod
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
@ -725,6 +749,28 @@ class JobResult(models.Model):
return job_result
def trigger_webhooks(self, event):
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(
**{f'type_{event}': True},
content_types=self.obj_type,
enabled=True
)
for webhook in webhooks:
rq_queue.enqueue(
"extras.webhooks_worker.process_webhook",
webhook=webhook,
model_name=self.obj_type.model,
event=event,
data=self.data,
timestamp=str(timezone.now()),
username=self.user.username
)
class ConfigRevision(models.Model):
"""
@ -767,7 +813,7 @@ class ConfigRevision(models.Model):
# Custom scripts & reports
#
class Script(JobResultsMixin, models.Model):
class Script(JobResultsMixin, WebhooksMixin, models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
@ -779,7 +825,7 @@ class Script(JobResultsMixin, models.Model):
# Reports
#
class Report(JobResultsMixin, models.Model):
class Report(JobResultsMixin, WebhooksMixin, models.Model):
"""
Dummy model used to generate permissions for reports. Does not exist in the database.
"""

View File

@ -85,8 +85,7 @@ def run_report(job_result, *args, **kwargs):
job_result.start()
report.run(job_result)
except Exception:
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
logging.error(f"Error during execution of report {job_result.name}")
finally:
# Schedule the next job if an interval has been set
@ -241,28 +240,23 @@ class Report(object):
self.pre_run()
try:
for method_name in self.test_methods:
self.active_test = method_name
test_method = getattr(self, method_name)
test_method()
if self.failed:
self.logger.warning("Report failed")
job_result.status = JobResultStatusChoices.STATUS_FAILED
else:
self.logger.info("Report completed successfully")
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
except Exception as e:
stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
logger.error(f"Exception raised during report execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.data = self._results
job_result.completed = timezone.now()
job_result.save()
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
finally:
job_result.terminate()
# Perform any post-run tasks
self.post_run()

View File

@ -460,36 +460,28 @@ def run_script(data, request, commit=True, *args, **kwargs):
the change_logging context manager (which is bypassed if commit == False).
"""
try:
with transaction.atomic():
script.output = script.run(data=data, commit=commit)
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
if not commit:
raise AbortTransaction()
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
except AbortScript as e:
script.log_failure(
f"Script aborted with error: {e}"
)
script.log_info("Database changes have been reverted due to error.")
logger.error(f"Script aborted with error: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
)
script.log_info("Database changes have been reverted due to error.")
logger.error(f"Exception raised during script execution: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
finally:
try:
with transaction.atomic():
script.output = script.run(data=data, commit=commit)
if not commit:
raise AbortTransaction()
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
job_result.data = ScriptOutputSerializer(script).data
job_result.save()
job_result.terminate()
except Exception as e:
if type(e) is AbortScript:
script.log_failure(f"Script aborted with error: {e}")
logger.error(f"Script aborted with error: {e}")
else:
stacktrace = traceback.format_exc()
script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
logger.error(f"Exception raised during script execution: {e}")
script.log_info("Database changes have been reverted due to error.")
job_result.data = ScriptOutputSerializer(script).data
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
logger.info(f"Script completed in {job_result.duration}")

View File

@ -146,6 +146,12 @@ class WebhookTable(NetBoxTable):
type_delete = columns.BooleanColumn(
verbose_name='Delete'
)
type_job_start = columns.BooleanColumn(
verbose_name='Job start'
)
type_job_end = columns.BooleanColumn(
verbose_name='Job end'
)
ssl_validation = columns.BooleanColumn(
verbose_name='SSL Validation'
)
@ -153,12 +159,13 @@ class WebhookTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Webhook
fields = (
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
'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',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'payload_url',
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start',
'type_job_end', 'http_method', 'payload_url',
)

View File

@ -89,12 +89,16 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
content_types = ContentType.objects.filter(model__in=['region', 'site', 'rack', 'location', 'device'])
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',
@ -102,7 +106,11 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
),
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',
@ -110,26 +118,56 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
),
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_content_types(self):
params = {'content_types': 'dcim.site'}
params = {'content_types': 'dcim.region'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
params = {'content_type_id': [ContentType.objects.get_for_model(Region).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_create(self):
@ -144,6 +182,14 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
params = {'type_delete': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_job_start(self):
params = {'type_job_start': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_job_end(self):
params = {'type_job_end': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -5,8 +5,8 @@ from django.conf import settings
from django_rq import job
from jinja2.exceptions import TemplateError
from .choices import ObjectChangeActionChoices
from .conditions import ConditionSet
from .constants import WEBHOOK_EVENT_TYPES
from .webhooks import generate_signature
logger = logging.getLogger('netbox.webhooks_worker')
@ -28,7 +28,7 @@ def eval_conditions(webhook, data):
@job('default')
def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id):
def process_webhook(webhook, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
"""
Make a POST request to the defined Webhook
"""
@ -38,14 +38,17 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
# Prepare context data for headers & body templates
context = {
'event': dict(ObjectChangeActionChoices)[event].lower(),
'event': WEBHOOK_EVENT_TYPES[event],
'timestamp': timestamp,
'model': model_name,
'username': username,
'request_id': request_id,
'data': data,
'snapshots': snapshots,
}
if snapshots:
context.update({
'snapshots': snapshots
})
# Build the headers for the HTTP request
headers = {

View File

@ -40,6 +40,14 @@
<th scope="row">Delete</th>
<td>{% checkmark object.type_delete %}</td>
</tr>
<tr>
<th scope="row">Job start</th>
<td>{% checkmark object.type_job_start %}</td>
</tr>
<tr>
<th scope="row">Job end</th>
<td>{% checkmark object.type_job_end %}</td>
</tr>
</table>
</div>
</div>