Implements #81 - webhook event backend (#1640)

* merge branch develop

* bugfix, signals for virtualization's class wasn't correctly defined

* updated webhooks for 2.4 and cleanup

* updated docs to cover changes to supervisor config

* review changes and further cleanup

* updated redis connection settings

* cleanup settings
This commit is contained in:
John Anderson
2018-05-30 11:19:10 -04:00
committed by Jeremy Stretch
parent fe9ae933e2
commit 80a1b23f6f
29 changed files with 782 additions and 3 deletions

View File

@@ -9,3 +9,8 @@ class CircuitsConfig(AppConfig):
def ready(self):
import circuits.signals
# register webhook signals
from extras.webhooks import register_signals
from .models import Circuit, Provider
register_signals([Circuit, Provider])

View File

@@ -61,6 +61,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
serializer = 'circuits.api.serializers.ProviderSerializer'
class Meta:
ordering = ['name']
@@ -175,6 +177,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]
serializer = 'circuits.api.serializers.CircuitSerializer'
class Meta:
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']

View File

@@ -8,4 +8,10 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM"
def ready(self):
import dcim.signals
# register webhook signals
from extras.webhooks import register_signals
from .models import Site, Rack, RackGroup, Device, Interface
register_signals([Site, Rack, Device, Interface, RackGroup])

View File

@@ -169,6 +169,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
]
serializer = 'dcim.api.serializers.SiteSerializer'
class Meta:
ordering = ['name']
@@ -249,6 +251,8 @@ class RackGroup(models.Model):
csv_headers = ['site', 'name', 'slug']
serializer = 'dcim.api.serializers.RackGroupSerializer'
class Meta:
ordering = ['site', 'name']
unique_together = [
@@ -397,6 +401,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
'desc_units', 'comments',
]
serializer = 'dcim.api.serializers.RackSerializer'
class Meta:
ordering = ['site', 'group', 'name']
unique_together = [
@@ -1243,6 +1249,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
]
serializer = 'dcim.api.serializers.DeviceSerializer'
class Meta:
ordering = ['name']
unique_together = [
@@ -1768,6 +1776,8 @@ class Interface(models.Model):
objects = InterfaceQuerySet.as_manager()
serializer = 'dcim.api.serializers.InterfaceSerializer'
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']

View File

@@ -0,0 +1,15 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
default_app_config = 'extras.apps.ExtrasConfig'
# check that django-rq is installed and we can connect to redis
if settings.WEBHOOK_BACKEND_ENABLED:
try:
import django_rq
except ImportError:
raise ImproperlyConfigured(
"django-rq is not installed! You must install this package per "
"the documentation to use the webhook backend."
)

View File

@@ -4,7 +4,11 @@ from django import forms
from django.contrib import admin
from django.utils.safestring import mark_safe
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
from utilities.forms import LaxURLField
from .models import (
CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction,
Webhook
)
def order_content_types(field):
@@ -15,6 +19,36 @@ def order_content_types(field):
field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
#
# Webhooks
#
class WebhookForm(forms.ModelForm):
payload_url = LaxURLField()
class Meta:
model = Webhook
exclude = []
def __init__(self, *args, **kwargs):
super(WebhookForm, self).__init__(*args, **kwargs)
order_content_types(self.fields['obj_type'])
@admin.register(Webhook)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
'type_delete', 'ssl_verification',
]
form = WebhookForm
def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()])
#
# Custom fields
#

View File

@@ -10,6 +10,7 @@ from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from users.api.serializers import NestedUserSerializer
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
from extras.constants import *
#

29
netbox/extras/apps.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import unicode_literals
from django.apps import AppConfig
from django.core.cache import caches
from django.db.utils import ProgrammingError
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
import extras.signals
# check that we can connect to redis
if settings.WEBHOOK_BACKEND_ENABLED:
try:
import redis
rs = redis.Redis(settings.REDIS_HOST,
settings.REDIS_PORT,
settings.REDIS_DB,
settings.REDIS_PASSWORD or None)
rs.ping()
except redis.exceptions.ConnectionError:
raise ImproperlyConfigured(
"Unable to connect to the redis database. You must provide "
"connection settings to redis per the documentation."
)

View File

@@ -97,3 +97,21 @@ LOG_LEVEL_CODES = {
LOG_WARNING: 'warning',
LOG_FAILURE: 'failure',
}
# webhook content types
WEBHOOK_CT_JSON = 1
WEBHOOK_CT_X_WWW_FORM_ENCODED = 2
WEBHOOK_CT_CHOICES = (
(WEBHOOK_CT_JSON, 'application/json'),
(WEBHOOK_CT_X_WWW_FORM_ENCODED, 'application/x-www-form-urlencoded'),
)
# Models which support registered webhooks
WEBHOOK_MODELS = (
'provider', 'circuit', # Circuits
'site', 'rack', 'rackgroup', 'device', 'interface', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vlangroup', 'vrf', # IPAM
'service',
'tenant', 'tenantgroup', # Tenancy
'cluster', 'clustergroup', 'virtualmachine', # Virtualization
)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-05-23 16:35
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0011_django2'),
]
operations = [
migrations.CreateModel(
name='Webhook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, unique=True)),
('type_create', models.BooleanField(default=False, help_text='A POST will be sent to the URL when the object type(s) is created.')),
('type_update', models.BooleanField(default=False, help_text='A POST will be sent to the URL when the object type(s) is updated.')),
('type_delete', models.BooleanField(default=False, help_text='A POST will be sent to the URL when the object type(s) is deleted.')),
('payload_url', models.CharField(max_length=500, verbose_name='A POST will be sent to this URL based on the webhook criteria.')),
('http_content_type', models.PositiveSmallIntegerField(choices=[(1, 'application/json'), (2, 'application/x-www-form-urlencoded')], default=1)),
('secret', models.CharField(blank=True, help_text="When provided the request will include a 'X-Hook-Signature' header which is a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.", max_length=255)),
('enabled', models.BooleanField(default=True)),
('ssl_verification', models.BooleanField(default=True, help_text='By default, use of proper SSL is verified. Disable with caution!')),
('obj_type', models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object(s)')),
],
),
migrations.AlterUniqueTogether(
name='webhook',
unique_together=set([('payload_url', 'type_create', 'type_update', 'type_delete')]),
),
]

View File

@@ -21,6 +21,80 @@ from utilities.utils import foreground_color
from .constants import *
#
# Webhooks
#
class Webhook(models.Model):
"""
Webhook model that represents all the details for an endoint and how to make a request to
that endpoint with the configured payload.
"""
obj_type = models.ManyToManyField(
ContentType,
related_name='webhooks',
verbose_name='Object(s)',
limit_choices_to={'model__in': WEBHOOK_MODELS},
help_text="The object(s) to which this Webhook applies."
)
name = models.CharField(
max_length=150,
unique=True
)
type_create = models.BooleanField(
default=False,
help_text="A POST will be sent to the URL when the object type(s) is created."
)
type_update = models.BooleanField(
default=False,
help_text="A POST will be sent to the URL when the object type(s) is updated."
)
type_delete = models.BooleanField(
default=False,
help_text="A POST will be sent to the URL when the object type(s) is deleted."
)
payload_url = models.CharField(
max_length=500,
verbose_name="A POST will be sent to this URL based on the webhook criteria."
)
http_content_type = models.PositiveSmallIntegerField(
choices=WEBHOOK_CT_CHOICES,
default=WEBHOOK_CT_JSON
)
secret = models.CharField(
max_length=255,
blank=True,
help_text="When provided the request will include a 'X-Hook-Signature' "
"header which is a HMAC hex digest of the payload body using "
"the secret as the key. The secret is not transmitted in "
"the request."
)
enabled = models.BooleanField(
default=True
)
ssl_verification = models.BooleanField(
default=True,
help_text="By default, use of proper SSL is verified. Disable with caution!"
)
class Meta:
unique_together = ('payload_url', 'type_create', "type_update", "type_delete",)
def __str__(self):
return self.name
def clean(self):
"""
Validate model
"""
if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError(
"You must select at least one type. Either create, update, or delete."
)
#
# Custom fields
#

16
netbox/extras/signals.py Normal file
View File

@@ -0,0 +1,16 @@
from __future__ import unicode_literals
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.core.cache import caches
from .models import Webhook
@receiver((post_save, post_delete), sender=Webhook)
def update_webhook_cache(**kwargs):
"""
When a Webhook has been modified, update the webhook cache.
"""
cache = caches['default']
cache.set('webhook_cache', Webhook.objects.all())

142
netbox/extras/webhooks.py Normal file
View File

@@ -0,0 +1,142 @@
import time
from importlib import import_module
from django.db.models.signals import post_save, post_delete
from django.conf import settings
from django.core.cache import caches
from django.dispatch import Signal
from django.contrib.contenttypes.models import ContentType
from utilities.utils import dynamic_import
from .models import Webhook
#
# Webhooks signals regiters and receivers
#
def get_or_set_webhook_cache():
"""
Retrieve the webhook cache. If it is None set it to the current
Webhook queryset
"""
cache = caches['default']
webhook_cache = cache.get('webhook_cache', None)
if webhook_cache is None:
webhook_cache = Webhook.objects.all()
cache.set('webhook_cache', webhook_cache)
return webhook_cache
def enqueue_webhooks(webhooks, model_class, data, event, signal_received_timestamp):
"""
Serialize data and enqueue webhooks
"""
serializer_context = {
'request': None,
}
if isinstance(data, list):
serializer_property = data[0].serializer
serializer_cls = dynamic_import(serializer_property)
serialized_data = serializer_cls(data, context=serializer_context, many=True)
else:
serializer_property = data.serializer
serializer_cls = dynamic_import(serializer_property)
serialized_data = serializer_cls(data, context=serializer_context)
from django_rq import get_queue
webhook_queue = get_queue('default')
for webhook in webhooks:
webhook_queue.enqueue("extras.webhooks_worker.process_webhook",
webhook,
serialized_data.data,
model_class,
event,
signal_received_timestamp)
def post_save_receiver(sender, instance, created, **kwargs):
"""
Receives post_save signals from registered models. If the webhook
backend is enabled, queue any webhooks that apply to the event.
"""
if settings.WEBHOOK_BACKEND_ENABLED:
signal_received_timestamp = time.time()
webhook_cache = get_or_set_webhook_cache()
# look for any webhooks that match this event
updated = not created
obj_type = ContentType.objects.get_for_model(sender)
webhooks = [
x
for x in webhook_cache
if (
x.enabled and x.type_create == created or x.type_update == updated and
obj_type in x.obj_type.all()
)
]
event = 'created' if created else 'updated'
if webhooks:
enqueue_webhooks(webhooks, sender, instance, event, signal_received_timestamp)
def post_delete_receiver(sender, instance, **kwargs):
"""
Receives post_delete signals from registered models. If the webhook
backend is enabled, queue any webhooks that apply to the event.
"""
if settings.WEBHOOK_BACKEND_ENABLED:
signal_received_timestamp = time.time()
webhook_cache = get_or_set_webhook_cache()
obj_type = ContentType.objects.get_for_model(sender)
# look for any webhooks that match this event
webhooks = [x for x in webhook_cache if x.enabled and x.type_delete and obj_type in x.obj_type.all()]
if webhooks:
enqueue_webhooks(webhooks, sender, instance, 'deleted', signal_received_timestamp)
def bulk_operation_receiver(sender, **kwargs):
"""
Receives bulk_operation_signal signals from registered models. If the webhook
backend is enabled, queue any webhooks that apply to the event.
"""
if settings.WEBHOOK_BACKEND_ENABLED:
signal_received_timestamp = time.time()
event = kwargs['event']
webhook_cache = get_or_set_webhook_cache()
obj_type = ContentType.objects.get_for_model(sender)
# look for any webhooks that match this event
if event == 'created':
webhooks = [x for x in webhook_cache if x.enabled and x.type_create and obj_type in x.obj_type.all()]
elif event == 'updated':
webhooks = [x for x in webhook_cache if x.enabled and x.type_update and obj_type in x.obj_type.all()]
elif event == 'deleted':
webhooks = [x for x in webhook_cache if x.enabled and x.type_delete and obj_type in x.obj_type.all()]
else:
webhooks = None
if webhooks:
enqueue_webhooks(webhooks, sender, list(kwargs['instances']), event, signal_received_timestamp)
# the bulk operation signal is used to overcome signals not being sent for bulk model changes
bulk_operation_signal = Signal(providing_args=["instances", "event"])
bulk_operation_signal.connect(bulk_operation_receiver)
def register_signals(senders):
"""
Take a list of senders (Models) and register them to the post_save
and post_delete signal receivers.
"""
if settings.WEBHOOK_BACKEND_ENABLED:
# only register signals if the backend is enabled
# this reduces load by not firing signals if the
# webhook backend feature is disabled
for sender in senders:
post_save.connect(post_save_receiver, sender=sender)
post_delete.connect(post_delete_receiver, sender=sender)

View File

@@ -0,0 +1,52 @@
import requests
import hmac
import hashlib
from rq.utils import import_attribute
from django_rq import job
from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED
@job('default')
def process_webhook(webhook, data, model_class, event, timestamp):
"""
Make a POST request to the defined Webhook
"""
payload = {
'event': event,
'timestamp': timestamp,
'model': model_class.__name__,
'data': data
}
headers = {
'Content-Type': webhook.get_http_content_type_display(),
}
params = {
'method': 'POST',
'url': webhook.payload_url,
'headers': headers
}
if webhook.http_content_type == WEBHOOK_CT_JSON:
params.update({'json': payload})
elif webhook.http_content_type == WEBHOOK_CT_X_WWW_FORM_ENCODED:
params.update({'data': payload})
prepared_request = requests.Request(**params).prepare()
if webhook.secret != '':
# sign the request with the secret
hmac_prep = hmac.new(bytearray(webhook.secret, 'utf8'), prepared_request.body, digestmod=hashlib.sha512)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
with requests.Session() as session:
session.verify = webhook.ssl_verification
response = session.send(prepared_request)
if response.status_code >= 200 and response.status_code <= 299:
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else:
raise requests.exceptions.RequestException(
"Status {} returned, webhook FAILED to process.".format(response.status_code)
)

View File

@@ -6,3 +6,10 @@ from django.apps import AppConfig
class IPAMConfig(AppConfig):
name = "ipam"
verbose_name = "IPAM"
def ready(self):
# register webhook signals
from extras.webhooks import register_signals
from .models import Aggregate, Prefix, IPAddress, VLAN, VRF, VLANGroup, Service
register_signals([Aggregate, Prefix, IPAddress, VLAN, VRF, VLANGroup, Service])

View File

@@ -61,6 +61,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
serializer = 'ipam.api.serializers.VRFSerializer'
class Meta:
ordering = ['name', 'rd']
verbose_name = 'VRF'
@@ -162,6 +164,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['prefix', 'rir', 'date_added', 'description']
serializer = 'ipam.api.serializers.AggregateSerializer'
class Meta:
ordering = ['family', 'prefix']
@@ -336,6 +340,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
serializer = 'ipam.api.serializers.PrefixSerializer'
class Meta:
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
@@ -481,6 +487,16 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
prefix_size -= 2
return int(float(child_count) / prefix_size * 100)
def new_subnet(self):
if self.family == 4:
if self.prefix.prefixlen <= 30:
return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
return None
if self.family == 6:
if self.prefix.prefixlen <= 126:
return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
return None
class IPAddressManager(models.Manager):
@@ -577,6 +593,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
'description',
]
serializer = 'ipam.api.serializers.IPAddressSerializer'
class Meta:
ordering = ['family', 'address']
verbose_name = 'IP address'
@@ -673,6 +691,8 @@ class VLANGroup(models.Model):
csv_headers = ['name', 'slug', 'site']
serializer = 'ipam.api.serializers.VLANGroupSerializer'
class Meta:
ordering = ['site', 'name']
unique_together = [
@@ -770,6 +790,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
serializer = 'ipam.api.serializers.VLANSerializer'
class Meta:
ordering = ['site', 'group', 'vid']
unique_together = [
@@ -864,6 +886,8 @@ class Service(CreatedUpdatedModel):
blank=True
)
serializer = 'ipam.api.serializers.ServiceSerializer'
class Meta:
ordering = ['protocol', 'port']

View File

@@ -118,6 +118,20 @@ PAGINATE_COUNT = 50
# prefer IPv4 instead.
PREFER_IPV4 = False
# The Webhook event backend is disabled by default. Set this to True to enable it. Besure to follow the documentation
# on first enabling the required components for the webhook backend.
WEBHOOK_BACKEND_ENABLED = False
# Redis settings. Redis is used in webhook backend so WEBHOOK_BACKEND_ENABLED must be enabled for these
# to mean anything. Please refer to the netbox documentation on the webhook backend.
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'DEFAULT_TIMEOUT': 300,
'PASSWORD': '',
'DB': 0,
}
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'

View File

@@ -64,6 +64,8 @@ NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
WEBHOOK_BACKEND_ENABLED = getattr(configuration, 'WEBHOOK_BACKEND_ENABLED', False)
REDIS = getattr(configuration, 'REDIS', {})
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -109,6 +111,13 @@ DATABASES = {
'default': configuration.DATABASE,
}
# Redis
REDIS_HOST = REDIS.get('REDIS_HOST', 'localhost')
REDIS_PORT = REDIS.get('REDIS_PORT', 6379)
REDIS_DEFAULT_TIMEOUT = REDIS.get('REDIS_DEFAULT_TIMEOUT', 300)
REDIS_PASSWORD = REDIS.get('REDIS_PASSWORD', '')
REDIS_DB = REDIS.get('REDIS_DB', 0)
# Email
EMAIL_HOST = EMAIL.get('SERVER')
EMAIL_PORT = EMAIL.get('PORT', 25)
@@ -119,7 +128,7 @@ SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
EMAIL_SUBJECT_PREFIX = '[NetBox] '
# Installed applications
INSTALLED_APPS = (
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -145,7 +154,11 @@ INSTALLED_APPS = (
'utilities',
'virtualization',
'drf_yasg',
)
]
# only load django-rq if the webhook backend is enabled
if WEBHOOK_BACKEND_ENABLED:
INSTALLED_APPS.append('django_rq')
# Middleware
MIDDLEWARE = (
@@ -246,6 +259,17 @@ REST_FRAMEWORK = {
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
}
# Django RQ (Webhook backend)
RQ_QUEUES = {
'default': {
'HOST': REDIS_HOST,
'PORT': REDIS_PORT,
'DB': REDIS_DB,
'PASSWORD': REDIS_PASSWORD,
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
}
}
# drf_yasg settings for Swagger
SWAGGER_SETTINGS = {
'DEFAULT_FIELD_INSPECTORS': [
@@ -278,6 +302,14 @@ INTERNAL_IPS = (
'::1',
)
# Django CACHE - local memory cache
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'webhooks',
}
}
try:
HOSTNAME = socket.gethostname()

View File

@@ -64,6 +64,12 @@ _patterns = [
]
if settings.WEBHOOK_BACKEND_ENABLED:
_patterns += [
url(r'^admin/webhook-backend-status/', include('django_rq.urls')),
]
if settings.DEBUG:
import debug_toolbar
_patterns += [

View File

@@ -5,3 +5,10 @@ from django.apps import AppConfig
class TenancyConfig(AppConfig):
name = 'tenancy'
def ready(self):
# register webhook signals
from extras.webhooks import register_signals
from .models import Tenant, TenantGroup
register_signals([Tenant, TenantGroup])

View File

@@ -25,6 +25,8 @@ class TenantGroup(models.Model):
csv_headers = ['name', 'slug']
serializer = 'tenancy.api.serializers.TenantGroupSerializer'
class Meta:
ordering = ['name']
@@ -79,6 +81,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
serializer = 'tenancy.api.serializers.TenantSerializer'
class Meta:
ordering = ['group', 'name']

View File

@@ -71,3 +71,14 @@ def foreground_color(bg_color):
return '000000'
else:
return 'ffffff'
def dynamic_import(name):
"""
Dynamically import a class from an absolute path string
"""
components = name.split('.')
mod = __import__(components[0])
for comp in components[1:]:
mod = getattr(mod, comp)
return mod

View File

@@ -20,6 +20,7 @@ from django.views.generic import View
from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
from extras.webhooks import bulk_operation_signal
from utilities.utils import queryset_to_csv
from utilities.forms import BootstrapMixin, CSVDataField
from .constants import M2M_FIELD_TYPES
@@ -772,6 +773,9 @@ class ComponentCreateView(View):
field_links.append(field_link)
getattr(self.model, field).through.objects.bulk_create(field_links)
# send the bulk operations signal for webhooks
bulk_operation_signal.send(sender=self.model, instances=new_components, event="created")
messages.success(request, "Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent
))
@@ -848,6 +852,10 @@ class BulkComponentCreateView(View):
if not form.errors:
self.model.objects.bulk_create(new_components)
# send the bulk operations signal for webhooks
bulk_operation_signal.send(sender=self.model, instances=new_components, event="created")
messages.success(request, "Added {} {} to {} {}.".format(
len(new_components),
self.model._meta.verbose_name_plural,

View File

@@ -0,0 +1 @@
default_app_config = 'virtualization.apps.VirtualizationConfig'

View File

@@ -5,3 +5,10 @@ from django.apps import AppConfig
class VirtualizationConfig(AppConfig):
name = 'virtualization'
def ready(self):
# register webhook signals
from extras.webhooks import register_signals
from .models import Cluster, ClusterGroup, VirtualMachine
register_signals([Cluster, VirtualMachine, ClusterGroup])

View File

@@ -68,6 +68,8 @@ class ClusterGroup(models.Model):
csv_headers = ['name', 'slug']
serializer = 'virtualization.api.serializers.ClusterGroupSerializer'
class Meta:
ordering = ['name']
@@ -129,6 +131,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'type', 'group', 'site', 'comments']
serializer = 'virtualization.api.serializers.ClusterSerializer'
class Meta:
ordering = ['name']
@@ -251,6 +255,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
]
serializer = 'virtualization.api.serializers.VirtualMachineSerializer'
class Meta:
ordering = ['name']