mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 17:26:10 -06:00
Merge branch 'feature' into 10054-ui-object-selectors
This commit is contained in:
commit
e04c62ada3
@ -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.
|
The events which will trigger the webhook. At least one event type must be selected.
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|-----------|--------------------------------------|
|
|------------|--------------------------------------|
|
||||||
| Creations | A new object has been created |
|
| Creations | A new object has been created |
|
||||||
| Updates | An existing object has been modified |
|
| Updates | An existing object has been modified |
|
||||||
| Deletions | An object has been deleted |
|
| Deletions | An object has been deleted |
|
||||||
|
| Job starts | A job for an object starts |
|
||||||
|
| Job ends | A job for an object terminates |
|
||||||
|
|
||||||
### URL
|
### 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.
|
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
|
### SSL Verification
|
||||||
|
|
||||||
Controls whether validation of the receiver's SSL certificate is enforced when HTTPS is used.
|
Controls whether validation of the receiver's SSL certificate is enforced when HTTPS is used.
|
||||||
|
@ -27,6 +27,8 @@ A new ASN range model has been introduced to facilitate the provisioning of new
|
|||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
* [#7947](https://github.com/netbox-community/netbox/issues/7947) - Enable marking IP ranges as fully utilized
|
* [#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
|
* [#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
|
* [#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)
|
* [#10374](https://github.com/netbox-community/netbox/issues/10374) - Require unique tenant names & slugs per group (not globally)
|
||||||
|
@ -22,8 +22,9 @@ def sync_datasource(job_result, *args, **kwargs):
|
|||||||
# Update the search cache for DataFiles belonging to this source
|
# Update the search cache for DataFiles belonging to this source
|
||||||
search_backend.cache(datasource.datafiles.iterator())
|
search_backend.cache(datasource.datafiles.iterator())
|
||||||
|
|
||||||
|
job_result.terminate()
|
||||||
|
|
||||||
except SyncError as e:
|
except SyncError as e:
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||||
job_result.save()
|
|
||||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
|
@ -475,6 +475,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
|||||||
default=None
|
default=None
|
||||||
)
|
)
|
||||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||||
|
bridge = NestedInterfaceTemplateSerializer(required=False, allow_null=True)
|
||||||
poe_mode = ChoiceField(
|
poe_mode = ChoiceField(
|
||||||
choices=InterfacePoEModeChoices,
|
choices=InterfacePoEModeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
@ -489,7 +490,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = InterfaceTemplate
|
model = InterfaceTemplate
|
||||||
fields = [
|
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',
|
'poe_mode', 'poe_type', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -810,15 +810,24 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceTemplateForm(ModularComponentTemplateForm):
|
class InterfaceTemplateForm(ModularComponentTemplateForm):
|
||||||
|
bridge = DynamicModelChoiceField(
|
||||||
|
queryset=InterfaceTemplate.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'devicetype_id': '$device_type',
|
||||||
|
'moduletype_id': '$module_type',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
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'))
|
('PoE', ('poe_mode', 'poe_type'))
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InterfaceTemplate
|
model = InterfaceTemplate
|
||||||
fields = [
|
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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
19
netbox/dcim/migrations/0171_devicetype_add_bridge.py
Normal file
19
netbox/dcim/migrations/0171_devicetype_add_bridge.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -350,6 +350,14 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
|||||||
default=False,
|
default=False,
|
||||||
verbose_name='Management only'
|
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(
|
poe_mode = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=InterfacePoEModeChoices,
|
choices=InterfacePoEModeChoices,
|
||||||
@ -365,6 +373,19 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
|||||||
|
|
||||||
component_model = Interface
|
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):
|
def instantiate(self, **kwargs):
|
||||||
return self.component_model(
|
return self.component_model(
|
||||||
name=self.resolve_name(kwargs.get('module')),
|
name=self.resolve_name(kwargs.get('module')),
|
||||||
@ -385,6 +406,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
|||||||
'mgmt_only': self.mgmt_only,
|
'mgmt_only': self.mgmt_only,
|
||||||
'label': self.label,
|
'label': self.label,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
|
'bridge': self.bridge.name if self.bridge else None,
|
||||||
'poe_mode': self.poe_mode,
|
'poe_mode': self.poe_mode,
|
||||||
'poe_type': self.poe_type,
|
'poe_type': self.poe_type,
|
||||||
}
|
}
|
||||||
|
@ -460,6 +460,20 @@ class Platform(OrganizationalModel):
|
|||||||
return reverse('dcim:platform', args=[self.pk])
|
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):
|
class Device(PrimaryModel, ConfigContextModel):
|
||||||
"""
|
"""
|
||||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
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())
|
self._instantiate_components(self.device_type.devicebaytemplates.all())
|
||||||
# Disable bulk_create to accommodate MPTT
|
# Disable bulk_create to accommodate MPTT
|
||||||
self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
|
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
|
# Update Site and Rack assignment for any child Devices
|
||||||
devices = Device.objects.filter(parent_bay__device=self)
|
devices = Device.objects.filter(parent_bay__device=self)
|
||||||
@ -1090,6 +1106,9 @@ class Module(PrimaryModel, ConfigContextModel):
|
|||||||
update_fields=update_fields
|
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
|
# Virtual chassis
|
||||||
|
@ -187,7 +187,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
|||||||
|
|
||||||
class Meta(ComponentTemplateTable.Meta):
|
class Meta(ComponentTemplateTable.Meta):
|
||||||
model = models.InterfaceTemplate
|
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"
|
empty_text = "None"
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,9 +68,10 @@ class WebhookSerializer(ValidatedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Webhook
|
model = Webhook
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
|
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete',
|
||||||
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
'type_job_start', 'type_job_end', 'payload_url', 'enabled', 'http_method', 'http_content_type',
|
||||||
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
|
'additional_headers', 'body_template', 'secret', 'conditions', 'ssl_verification', 'ca_file_path',
|
||||||
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,8 +1,16 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
# Webhook content types
|
# Webhooks
|
||||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||||
|
|
||||||
|
WEBHOOK_EVENT_TYPES = {
|
||||||
|
'create': 'created',
|
||||||
|
'update': 'updated',
|
||||||
|
'delete': 'deleted',
|
||||||
|
'job_start': 'job_started',
|
||||||
|
'job_end': 'job_ended',
|
||||||
|
}
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
DEFAULT_DASHBOARD = [
|
DEFAULT_DASHBOARD = [
|
||||||
{
|
{
|
||||||
|
@ -48,8 +48,8 @@ class WebhookFilterSet(BaseFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Webhook
|
model = Webhook
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method',
|
'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'payload_url',
|
||||||
'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
'enabled', 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||||
]
|
]
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
|
@ -140,6 +140,14 @@ class WebhookBulkEditForm(BulkEditForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect()
|
||||||
)
|
)
|
||||||
|
type_job_start = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect()
|
||||||
|
)
|
||||||
|
type_job_end = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect()
|
||||||
|
)
|
||||||
http_method = forms.ChoiceField(
|
http_method = forms.ChoiceField(
|
||||||
choices=add_blank_choice(WebhookHttpMethodChoices),
|
choices=add_blank_choice(WebhookHttpMethodChoices),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -116,9 +116,9 @@ class WebhookImportForm(CSVModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Webhook
|
model = Webhook
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
|
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'type_job_start',
|
||||||
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
|
'type_job_end', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template',
|
||||||
'ca_file_path'
|
'secret', 'ssl_verification', 'ca_file_path'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -222,7 +222,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
('Attributes', ('content_type_id', 'http_method', 'enabled')),
|
('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(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
||||||
@ -244,19 +244,36 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
),
|
||||||
|
label=_('Object creations')
|
||||||
)
|
)
|
||||||
type_update = forms.NullBooleanField(
|
type_update = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
),
|
||||||
|
label=_('Object updates')
|
||||||
)
|
)
|
||||||
type_delete = forms.NullBooleanField(
|
type_delete = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(
|
widget=forms.Select(
|
||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
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')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -154,7 +154,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Webhook', ('name', 'content_types', 'enabled')),
|
('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', (
|
('HTTP Request', (
|
||||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
'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_create': 'Creations',
|
||||||
'type_update': 'Updates',
|
'type_update': 'Updates',
|
||||||
'type_delete': 'Deletions',
|
'type_delete': 'Deletions',
|
||||||
|
'type_job_start': 'Job executions',
|
||||||
|
'type_job_end': 'Job terminations',
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
@ -41,16 +41,16 @@ class Command(BaseCommand):
|
|||||||
the change_logging context manager (which is bypassed if commit == False).
|
the change_logging context manager (which is bypassed if commit == False).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
try:
|
||||||
script.output = script.run(data=data, commit=commit)
|
with transaction.atomic():
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
script.output = script.run(data=data, commit=commit)
|
||||||
|
if not commit:
|
||||||
if not commit:
|
raise AbortTransaction()
|
||||||
raise AbortTransaction()
|
except AbortTransaction:
|
||||||
|
script.log_info("Database changes have been reverted automatically.")
|
||||||
except AbortTransaction:
|
clear_webhooks.send(request)
|
||||||
script.log_info("Database changes have been reverted automatically.")
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
clear_webhooks.send(request)
|
job_result.terminate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
script.log_failure(
|
script.log_failure(
|
||||||
@ -58,11 +58,9 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
script.log_info("Database changes have been reverted due to error.")
|
script.log_info("Database changes have been reverted due to error.")
|
||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
|
||||||
clear_webhooks.send(request)
|
clear_webhooks.send(request)
|
||||||
finally:
|
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
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}")
|
logger.info(f"Script completed in {job_result.duration}")
|
||||||
|
|
||||||
|
23
netbox/extras/migrations/0088_jobresult_webhooks.py
Normal file
23
netbox/extras/migrations/0088_jobresult_webhooks.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -5,7 +5,6 @@ from django.conf import settings
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import MinValueValidator, ValidationError
|
from django.core.validators import MinValueValidator, ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -27,7 +26,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT
|
|||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import (
|
from netbox.models.features import (
|
||||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
|
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
|
||||||
TagsMixin,
|
TagsMixin, WebhooksMixin,
|
||||||
)
|
)
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.utils import render_jinja2
|
from utilities.utils import render_jinja2
|
||||||
@ -65,15 +64,23 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
)
|
)
|
||||||
type_create = models.BooleanField(
|
type_create = models.BooleanField(
|
||||||
default=False,
|
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(
|
type_update = models.BooleanField(
|
||||||
default=False,
|
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(
|
type_delete = models.BooleanField(
|
||||||
default=False,
|
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(
|
payload_url = models.CharField(
|
||||||
max_length=500,
|
max_length=500,
|
||||||
@ -159,8 +166,12 @@ class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# At least one action type must be selected
|
# At least one action type must be selected
|
||||||
if not self.type_create and not self.type_delete and not self.type_update:
|
if not any([
|
||||||
raise ValidationError("At least one type must be selected: create, update, and/or delete.")
|
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:
|
if self.conditions:
|
||||||
try:
|
try:
|
||||||
@ -678,19 +689,32 @@ class JobResult(models.Model):
|
|||||||
"""
|
"""
|
||||||
Record the job's start time and update its status to "running."
|
Record the job's start time and update its status to "running."
|
||||||
"""
|
"""
|
||||||
if self.started is None:
|
if self.started is not None:
|
||||||
self.started = timezone.now()
|
return
|
||||||
self.status = JobResultStatusChoices.STATUS_RUNNING
|
|
||||||
JobResult.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
|
|
||||||
|
|
||||||
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
|
Mark the job as completed, optionally specifying a particular termination status.
|
||||||
time is also set.
|
|
||||||
"""
|
"""
|
||||||
|
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
|
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
|
@classmethod
|
||||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
|
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
|
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):
|
class ConfigRevision(models.Model):
|
||||||
"""
|
"""
|
||||||
@ -767,7 +813,7 @@ class ConfigRevision(models.Model):
|
|||||||
# Custom scripts & reports
|
# 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.
|
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
|
# 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.
|
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||||
"""
|
"""
|
||||||
|
@ -85,8 +85,7 @@ def run_report(job_result, *args, **kwargs):
|
|||||||
job_result.start()
|
job_result.start()
|
||||||
report.run(job_result)
|
report.run(job_result)
|
||||||
except Exception:
|
except Exception:
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||||
job_result.save()
|
|
||||||
logging.error(f"Error during execution of report {job_result.name}")
|
logging.error(f"Error during execution of report {job_result.name}")
|
||||||
finally:
|
finally:
|
||||||
# Schedule the next job if an interval has been set
|
# Schedule the next job if an interval has been set
|
||||||
@ -241,28 +240,23 @@ class Report(object):
|
|||||||
self.pre_run()
|
self.pre_run()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
for method_name in self.test_methods:
|
for method_name in self.test_methods:
|
||||||
self.active_test = method_name
|
self.active_test = method_name
|
||||||
test_method = getattr(self, method_name)
|
test_method = getattr(self, method_name)
|
||||||
test_method()
|
test_method()
|
||||||
|
|
||||||
if self.failed:
|
if self.failed:
|
||||||
self.logger.warning("Report failed")
|
self.logger.warning("Report failed")
|
||||||
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
||||||
else:
|
else:
|
||||||
self.logger.info("Report completed successfully")
|
self.logger.info("Report completed successfully")
|
||||||
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||||
logger.error(f"Exception raised during report execution: {e}")
|
logger.error(f"Exception raised during report execution: {e}")
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||||
|
finally:
|
||||||
job_result.data = self._results
|
job_result.terminate()
|
||||||
job_result.completed = timezone.now()
|
|
||||||
job_result.save()
|
|
||||||
|
|
||||||
# Perform any post-run tasks
|
# Perform any post-run tasks
|
||||||
self.post_run()
|
self.post_run()
|
||||||
|
@ -460,36 +460,28 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
the change_logging context manager (which is bypassed if commit == False).
|
the change_logging context manager (which is bypassed if commit == False).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
try:
|
||||||
script.output = script.run(data=data, commit=commit)
|
with transaction.atomic():
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
script.output = script.run(data=data, commit=commit)
|
||||||
|
if not commit:
|
||||||
if not commit:
|
raise AbortTransaction()
|
||||||
raise AbortTransaction()
|
except AbortTransaction:
|
||||||
|
script.log_info("Database changes have been reverted automatically.")
|
||||||
except AbortTransaction:
|
clear_webhooks.send(request)
|
||||||
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:
|
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
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}")
|
logger.info(f"Script completed in {job_result.duration}")
|
||||||
|
|
||||||
|
@ -146,6 +146,12 @@ class WebhookTable(NetBoxTable):
|
|||||||
type_delete = columns.BooleanColumn(
|
type_delete = columns.BooleanColumn(
|
||||||
verbose_name='Delete'
|
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(
|
ssl_validation = columns.BooleanColumn(
|
||||||
verbose_name='SSL Validation'
|
verbose_name='SSL Validation'
|
||||||
)
|
)
|
||||||
@ -153,12 +159,13 @@ class WebhookTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Webhook
|
model = Webhook
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete',
|
||||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
|
'type_job_start', 'type_job_end', 'http_method', 'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||||
|
'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'type_job_start',
|
||||||
'payload_url',
|
'type_job_end', 'http_method', 'payload_url',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -89,12 +89,16 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
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 = (
|
webhooks = (
|
||||||
Webhook(
|
Webhook(
|
||||||
name='Webhook 1',
|
name='Webhook 1',
|
||||||
type_create=True,
|
type_create=True,
|
||||||
|
type_update=False,
|
||||||
|
type_delete=False,
|
||||||
|
type_job_start=False,
|
||||||
|
type_job_end=False,
|
||||||
payload_url='http://example.com/?1',
|
payload_url='http://example.com/?1',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
http_method='GET',
|
http_method='GET',
|
||||||
@ -102,7 +106,11 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
|||||||
),
|
),
|
||||||
Webhook(
|
Webhook(
|
||||||
name='Webhook 2',
|
name='Webhook 2',
|
||||||
|
type_create=False,
|
||||||
type_update=True,
|
type_update=True,
|
||||||
|
type_delete=False,
|
||||||
|
type_job_start=False,
|
||||||
|
type_job_end=False,
|
||||||
payload_url='http://example.com/?2',
|
payload_url='http://example.com/?2',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
http_method='POST',
|
http_method='POST',
|
||||||
@ -110,26 +118,56 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
|||||||
),
|
),
|
||||||
Webhook(
|
Webhook(
|
||||||
name='Webhook 3',
|
name='Webhook 3',
|
||||||
|
type_create=False,
|
||||||
|
type_update=False,
|
||||||
type_delete=True,
|
type_delete=True,
|
||||||
|
type_job_start=False,
|
||||||
|
type_job_end=False,
|
||||||
payload_url='http://example.com/?3',
|
payload_url='http://example.com/?3',
|
||||||
enabled=False,
|
enabled=False,
|
||||||
http_method='PATCH',
|
http_method='PATCH',
|
||||||
ssl_verification=False,
|
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)
|
Webhook.objects.bulk_create(webhooks)
|
||||||
webhooks[0].content_types.add(content_types[0])
|
webhooks[0].content_types.add(content_types[0])
|
||||||
webhooks[1].content_types.add(content_types[1])
|
webhooks[1].content_types.add(content_types[1])
|
||||||
webhooks[2].content_types.add(content_types[2])
|
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):
|
def test_name(self):
|
||||||
params = {'name': ['Webhook 1', 'Webhook 2']}
|
params = {'name': ['Webhook 1', 'Webhook 2']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_content_types(self):
|
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)
|
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)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_type_create(self):
|
def test_type_create(self):
|
||||||
@ -144,6 +182,14 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
|||||||
params = {'type_delete': True}
|
params = {'type_delete': True}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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):
|
def test_enabled(self):
|
||||||
params = {'enabled': True}
|
params = {'enabled': True}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -5,8 +5,8 @@ from django.conf import settings
|
|||||||
from django_rq import job
|
from django_rq import job
|
||||||
from jinja2.exceptions import TemplateError
|
from jinja2.exceptions import TemplateError
|
||||||
|
|
||||||
from .choices import ObjectChangeActionChoices
|
|
||||||
from .conditions import ConditionSet
|
from .conditions import ConditionSet
|
||||||
|
from .constants import WEBHOOK_EVENT_TYPES
|
||||||
from .webhooks import generate_signature
|
from .webhooks import generate_signature
|
||||||
|
|
||||||
logger = logging.getLogger('netbox.webhooks_worker')
|
logger = logging.getLogger('netbox.webhooks_worker')
|
||||||
@ -28,7 +28,7 @@ def eval_conditions(webhook, data):
|
|||||||
|
|
||||||
|
|
||||||
@job('default')
|
@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
|
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
|
# Prepare context data for headers & body templates
|
||||||
context = {
|
context = {
|
||||||
'event': dict(ObjectChangeActionChoices)[event].lower(),
|
'event': WEBHOOK_EVENT_TYPES[event],
|
||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
'model': model_name,
|
'model': model_name,
|
||||||
'username': username,
|
'username': username,
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'data': data,
|
'data': data,
|
||||||
'snapshots': snapshots,
|
|
||||||
}
|
}
|
||||||
|
if snapshots:
|
||||||
|
context.update({
|
||||||
|
'snapshots': snapshots
|
||||||
|
})
|
||||||
|
|
||||||
# Build the headers for the HTTP request
|
# Build the headers for the HTTP request
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -40,6 +40,14 @@
|
|||||||
<th scope="row">Delete</th>
|
<th scope="row">Delete</th>
|
||||||
<td>{% checkmark object.type_delete %}</td>
|
<td>{% checkmark object.type_delete %}</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user