mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-12 06:42:16 -06:00
Merge pull request #3423 from netbox-community/3415-custom-scripts
Add custom scripting
This commit is contained in:
@@ -384,3 +384,34 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
widget=ContentTypeSelect(),
|
||||
label='Object Type'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptForm(BootstrapMixin, forms.Form):
|
||||
_commit = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
label="Commit changes",
|
||||
help_text="Commit changes to the database (uncheck for a dry-run)"
|
||||
)
|
||||
|
||||
def __init__(self, vars, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically populate fields for variables
|
||||
for name, var in vars.items():
|
||||
self.fields[name] = var.as_field()
|
||||
|
||||
# Move _commit to the end of the form
|
||||
self.fields.move_to_end('_commit', True)
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
"""
|
||||
A boolean indicating whether the form requires user input (ignore the _commit field).
|
||||
"""
|
||||
return bool(len(self.fields) > 1)
|
||||
|
||||
@@ -9,11 +9,12 @@ from django.utils import timezone
|
||||
from django.utils.functional import curry
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from extras.webhooks import enqueue_webhooks
|
||||
from .constants import (
|
||||
OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
|
||||
)
|
||||
from .models import ObjectChange
|
||||
from .signals import purge_changelog
|
||||
from .webhooks import enqueue_webhooks
|
||||
|
||||
_thread_locals = threading.local()
|
||||
|
||||
@@ -30,6 +31,10 @@ def cache_changed_object(instance, **kwargs):
|
||||
|
||||
def _record_object_deleted(request, instance, **kwargs):
|
||||
|
||||
# TODO: Can we cache deletions for later processing like we do for saves? Currently this will trigger an exception
|
||||
# when trying to serialize ManyToMany relations after the object has been deleted. This should be doable if we alter
|
||||
# log_change() to return ObjectChanges to be saved rather than saving them directly.
|
||||
|
||||
# Record that the object was deleted
|
||||
if hasattr(instance, 'log_change'):
|
||||
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
|
||||
@@ -41,6 +46,13 @@ def _record_object_deleted(request, instance, **kwargs):
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
def purge_objectchange_cache(sender, **kwargs):
|
||||
"""
|
||||
Delete any queued object changes waiting to be written.
|
||||
"""
|
||||
_thread_locals.changed_objects = None
|
||||
|
||||
|
||||
class ObjectChangeMiddleware(object):
|
||||
"""
|
||||
This middleware performs three functions in response to an object being created, updated, or deleted:
|
||||
@@ -74,9 +86,21 @@ class ObjectChangeMiddleware(object):
|
||||
post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
|
||||
post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
|
||||
|
||||
# Provide a hook for purging the change cache
|
||||
purge_changelog.connect(purge_objectchange_cache)
|
||||
|
||||
# Process the request
|
||||
response = self.get_response(request)
|
||||
|
||||
# If the change cache has been purged (e.g. due to an exception) abort the logging of all changes resulting from
|
||||
# this request.
|
||||
if _thread_locals.changed_objects is None:
|
||||
|
||||
# Delete ObjectChanges representing deletions, since these have already been written
|
||||
ObjectChange.objects.filter(request_id=request.id).delete()
|
||||
|
||||
return response
|
||||
|
||||
# Create records for any cached objects that were created/updated.
|
||||
for obj, action in _thread_locals.changed_objects:
|
||||
|
||||
|
||||
23
netbox/extras/migrations/0024_scripts.py
Normal file
23
netbox/extras/migrations/0024_scripts.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2 on 2019-08-12 15:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0023_fix_tag_sequences'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Script',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
],
|
||||
options={
|
||||
'permissions': (('run_script', 'Can run script'),),
|
||||
'managed': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -826,6 +826,21 @@ class ConfigContextModel(models.Model):
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
# Custom scripts
|
||||
#
|
||||
|
||||
class Script(models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
class Meta:
|
||||
managed = False
|
||||
permissions = (
|
||||
('run_script', 'Can run script'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Report results
|
||||
#
|
||||
|
||||
343
netbox/extras/scripts.py
Normal file
343
netbox/extras/scripts.py
Normal file
@@ -0,0 +1,343 @@
|
||||
from collections import OrderedDict
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pkgutil
|
||||
import time
|
||||
import traceback
|
||||
import yaml
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from ipam.formfields import IPFormField
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||
from .forms import ScriptForm
|
||||
from .signals import purge_changelog
|
||||
|
||||
|
||||
__all__ = [
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPNetworkVar',
|
||||
'ObjectVar',
|
||||
'Script',
|
||||
'StringVar',
|
||||
'TextVar',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Script variables
|
||||
#
|
||||
|
||||
class ScriptVariable:
|
||||
"""
|
||||
Base model for script variables
|
||||
"""
|
||||
form_field = forms.CharField
|
||||
|
||||
def __init__(self, label='', description='', default=None, required=True):
|
||||
|
||||
# Default field attributes
|
||||
self.field_attrs = {
|
||||
'help_text': description,
|
||||
'required': required
|
||||
}
|
||||
if label:
|
||||
self.field_attrs['label'] = label
|
||||
if default:
|
||||
self.field_attrs['initial'] = default
|
||||
|
||||
def as_field(self):
|
||||
"""
|
||||
Render the variable as a Django form field.
|
||||
"""
|
||||
form_field = self.form_field(**self.field_attrs)
|
||||
form_field.widget.attrs['class'] = 'form-control'
|
||||
|
||||
return form_field
|
||||
|
||||
|
||||
class StringVar(ScriptVariable):
|
||||
"""
|
||||
Character string representation. Can enforce minimum/maximum length and/or regex validation.
|
||||
"""
|
||||
def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Optional minimum/maximum lengths
|
||||
if min_length:
|
||||
self.field_attrs['min_length'] = min_length
|
||||
if max_length:
|
||||
self.field_attrs['max_length'] = max_length
|
||||
|
||||
# Optional regular expression validation
|
||||
if regex:
|
||||
self.field_attrs['validators'] = [
|
||||
RegexValidator(
|
||||
regex=regex,
|
||||
message='Invalid value. Must match regex: {}'.format(regex),
|
||||
code='invalid'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class TextVar(ScriptVariable):
|
||||
"""
|
||||
Free-form text data. Renders as a <textarea>.
|
||||
"""
|
||||
form_field = forms.CharField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.field_attrs['widget'] = forms.Textarea
|
||||
|
||||
|
||||
class IntegerVar(ScriptVariable):
|
||||
"""
|
||||
Integer representation. Can enforce minimum/maximum values.
|
||||
"""
|
||||
form_field = forms.IntegerField
|
||||
|
||||
def __init__(self, min_value=None, max_value=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Optional minimum/maximum values
|
||||
if min_value:
|
||||
self.field_attrs['min_value'] = min_value
|
||||
if max_value:
|
||||
self.field_attrs['max_value'] = max_value
|
||||
|
||||
|
||||
class BooleanVar(ScriptVariable):
|
||||
"""
|
||||
Boolean representation (true/false). Renders as a checkbox.
|
||||
"""
|
||||
form_field = forms.BooleanField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Boolean fields cannot be required
|
||||
self.field_attrs['required'] = False
|
||||
|
||||
|
||||
class ObjectVar(ScriptVariable):
|
||||
"""
|
||||
NetBox object representation. The provided QuerySet will determine the choices available.
|
||||
"""
|
||||
form_field = forms.ModelChoiceField
|
||||
|
||||
def __init__(self, queryset, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Queryset for field choices
|
||||
self.field_attrs['queryset'] = queryset
|
||||
|
||||
# Update form field for MPTT (nested) objects
|
||||
if issubclass(queryset.model, MPTTModel):
|
||||
self.form_field = TreeNodeChoiceField
|
||||
|
||||
|
||||
class FileVar(ScriptVariable):
|
||||
"""
|
||||
An uploaded file.
|
||||
"""
|
||||
form_field = forms.FileField
|
||||
|
||||
|
||||
class IPNetworkVar(ScriptVariable):
|
||||
"""
|
||||
An IPv4 or IPv6 prefix.
|
||||
"""
|
||||
form_field = IPFormField
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class BaseScript:
|
||||
"""
|
||||
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
|
||||
functionality for use in other subclasses.
|
||||
"""
|
||||
class Meta:
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
|
||||
# Initiate the log
|
||||
self.log = []
|
||||
|
||||
# Grab some info about the script
|
||||
self.filename = inspect.getfile(self.__class__)
|
||||
self.source = inspect.getsource(self.__class__)
|
||||
|
||||
def __str__(self):
|
||||
return getattr(self.Meta, 'name', self.__class__.__name__)
|
||||
|
||||
def _get_vars(self):
|
||||
vars = OrderedDict()
|
||||
|
||||
# Infer order from Meta.field_order (Python 3.5 and lower)
|
||||
field_order = getattr(self.Meta, 'field_order', [])
|
||||
for name in field_order:
|
||||
vars[name] = getattr(self, name)
|
||||
|
||||
# Default to order of declaration on class
|
||||
for name, attr in self.__class__.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
|
||||
return vars
|
||||
|
||||
def run(self, data):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
|
||||
def as_form(self, data=None, files=None):
|
||||
"""
|
||||
Return a Django form suitable for populating the context data required to run this Script.
|
||||
"""
|
||||
vars = self._get_vars()
|
||||
form = ScriptForm(vars, data, files)
|
||||
|
||||
return form
|
||||
|
||||
# Logging
|
||||
|
||||
def log_debug(self, message):
|
||||
self.log.append((LOG_DEFAULT, message))
|
||||
|
||||
def log_success(self, message):
|
||||
self.log.append((LOG_SUCCESS, message))
|
||||
|
||||
def log_info(self, message):
|
||||
self.log.append((LOG_INFO, message))
|
||||
|
||||
def log_warning(self, message):
|
||||
self.log.append((LOG_WARNING, message))
|
||||
|
||||
def log_failure(self, message):
|
||||
self.log.append((LOG_FAILURE, message))
|
||||
|
||||
# Convenience functions
|
||||
|
||||
def load_yaml(self, filename):
|
||||
"""
|
||||
Return data from a YAML file
|
||||
"""
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
with open(file_path, 'r') as datafile:
|
||||
data = yaml.load(datafile)
|
||||
|
||||
return data
|
||||
|
||||
def load_json(self, filename):
|
||||
"""
|
||||
Return data from a JSON file
|
||||
"""
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
with open(file_path, 'r') as datafile:
|
||||
data = json.load(datafile)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class Script(BaseScript):
|
||||
"""
|
||||
Classes which inherit this model will appear in the list of available scripts.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
#
|
||||
# Functions
|
||||
#
|
||||
|
||||
def is_script(obj):
|
||||
"""
|
||||
Returns True if the object is a Script.
|
||||
"""
|
||||
try:
|
||||
return issubclass(obj, Script) and obj != Script
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def is_variable(obj):
|
||||
"""
|
||||
Returns True if the object is a ScriptVariable.
|
||||
"""
|
||||
return isinstance(obj, ScriptVariable)
|
||||
|
||||
|
||||
def run_script(script, data, files, commit=True):
|
||||
"""
|
||||
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
|
||||
exists outside of the Script class to ensure it cannot be overridden by a script author.
|
||||
"""
|
||||
output = None
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
# Add files to form data
|
||||
for field_name, fileobj in files.items():
|
||||
data[field_name] = fileobj
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
start_time = time.time()
|
||||
output = script.run(data)
|
||||
end_time = time.time()
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
pass
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
|
||||
)
|
||||
commit = False
|
||||
finally:
|
||||
if not commit:
|
||||
# Delete all pending changelog entries
|
||||
purge_changelog.send(Script)
|
||||
script.log_info(
|
||||
"Database changes have been reverted automatically."
|
||||
)
|
||||
|
||||
# Calculate execution time
|
||||
if end_time is not None:
|
||||
execution_time = end_time - start_time
|
||||
else:
|
||||
execution_time = None
|
||||
|
||||
return output, execution_time
|
||||
|
||||
|
||||
def get_scripts():
|
||||
scripts = OrderedDict()
|
||||
|
||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
if hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
module_scripts = OrderedDict()
|
||||
for name, cls in inspect.getmembers(module, is_script):
|
||||
module_scripts[name] = cls
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
return scripts
|
||||
@@ -1,7 +1,12 @@
|
||||
from cacheops.signals import cache_invalidated, cache_read
|
||||
from django.dispatch import Signal
|
||||
from prometheus_client import Counter
|
||||
|
||||
|
||||
#
|
||||
# Caching
|
||||
#
|
||||
|
||||
cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
|
||||
cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
|
||||
cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
|
||||
@@ -20,3 +25,10 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
|
||||
|
||||
cache_read.connect(cache_read_collector)
|
||||
cache_invalidated.connect(cache_invalidated_collector)
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
purge_changelog = Signal()
|
||||
|
||||
37
netbox/extras/templatetags/log_levels.py
Normal file
37
netbox/extras/templatetags/log_levels.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django import template
|
||||
|
||||
from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('extras/templatetags/log_level.html')
|
||||
def log_level(level):
|
||||
"""
|
||||
Display a label indicating a syslog severity (e.g. info, warning, etc.).
|
||||
"""
|
||||
levels = {
|
||||
LOG_DEFAULT: {
|
||||
'name': 'Default',
|
||||
'class': 'default'
|
||||
},
|
||||
LOG_SUCCESS: {
|
||||
'name': 'Success',
|
||||
'class': 'success',
|
||||
},
|
||||
LOG_INFO: {
|
||||
'name': 'Info',
|
||||
'class': 'info'
|
||||
},
|
||||
LOG_WARNING: {
|
||||
'name': 'Warning',
|
||||
'class': 'warning'
|
||||
},
|
||||
LOG_FAILURE: {
|
||||
'name': 'Failure',
|
||||
'class': 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
return levels[level]
|
||||
157
netbox/extras/tests/test_scripts.py
Normal file
157
netbox/extras/tests/test_scripts.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.models import DeviceRole
|
||||
from extras.scripts import *
|
||||
|
||||
|
||||
class ScriptVariablesTest(TestCase):
|
||||
|
||||
def test_stringvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = StringVar(
|
||||
min_length=3,
|
||||
max_length=3,
|
||||
regex=r'[a-z]+'
|
||||
)
|
||||
|
||||
# Validate min_length enforcement
|
||||
data = {'var1': 'xx'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate max_length enforcement
|
||||
data = {'var1': 'xxxx'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate regex enforcement
|
||||
data = {'var1': 'ABC'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': 'abc'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], data['var1'])
|
||||
|
||||
def test_textvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = TextVar()
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': 'This is a test string'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], data['var1'])
|
||||
|
||||
def test_integervar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = IntegerVar(
|
||||
min_value=5,
|
||||
max_value=10
|
||||
)
|
||||
|
||||
# Validate min_value enforcement
|
||||
data = {'var1': 4}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate max_value enforcement
|
||||
data = {'var1': 11}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': 7}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], data['var1'])
|
||||
|
||||
def test_booleanvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = BooleanVar()
|
||||
|
||||
# Validate True
|
||||
data = {'var1': True}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], True)
|
||||
|
||||
# Validate False
|
||||
data = {'var1': False}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], False)
|
||||
|
||||
def test_objectvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = ObjectVar(
|
||||
queryset=DeviceRole.objects.all()
|
||||
)
|
||||
|
||||
# Populate some objects
|
||||
for i in range(1, 6):
|
||||
DeviceRole(
|
||||
name='Device Role {}'.format(i),
|
||||
slug='device-role-{}'.format(i)
|
||||
).save()
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': DeviceRole.objects.first().pk}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'].pk, data['var1'])
|
||||
|
||||
def test_filevar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = FileVar()
|
||||
|
||||
# Dummy file
|
||||
testfile = SimpleUploadedFile(
|
||||
name='test_file.txt',
|
||||
content=b'This is a dummy file for testing'
|
||||
)
|
||||
|
||||
# Validate valid data
|
||||
file_data = {'var1': testfile}
|
||||
form = TestScript().as_form(None, file_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], testfile)
|
||||
|
||||
def test_ipnetworkvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = IPNetworkVar()
|
||||
|
||||
# Validate IP network enforcement
|
||||
data = {'var1': '1.2.3'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
data = {'var1': '192.0.2.0/24'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
|
||||
@@ -28,13 +28,17 @@ urlpatterns = [
|
||||
path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||
|
||||
# Change logging
|
||||
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
|
||||
|
||||
# Reports
|
||||
path(r'reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
|
||||
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
|
||||
|
||||
# Change logging
|
||||
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
|
||||
# Scripts
|
||||
path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
@@ -20,6 +20,7 @@ from .forms import (
|
||||
)
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||
from .reports import get_report, get_reports
|
||||
from .scripts import get_scripts, run_script
|
||||
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
||||
|
||||
|
||||
@@ -353,3 +354,62 @@ class ReportRunView(PermissionRequiredMixin, View):
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
return redirect('extras:report', name=report.full_name)
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptListView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'scripts': get_scripts(),
|
||||
})
|
||||
|
||||
|
||||
class ScriptView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_script'
|
||||
|
||||
def _get_script(self, module, name):
|
||||
scripts = get_scripts()
|
||||
try:
|
||||
return scripts[module][name]()
|
||||
except KeyError:
|
||||
raise Http404
|
||||
|
||||
def get(self, request, module, name):
|
||||
|
||||
script = self._get_script(module, name)
|
||||
form = script.as_form()
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
def post(self, request, module, name):
|
||||
|
||||
# Permissions check
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
script = self._get_script(module, name)
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
output = None
|
||||
execution_time = None
|
||||
|
||||
if form.is_valid():
|
||||
commit = form.cleaned_data.pop('_commit')
|
||||
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
'output': output,
|
||||
'execution_time': execution_time,
|
||||
})
|
||||
|
||||
@@ -85,6 +85,7 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
||||
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('/')
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
|
||||
@@ -529,6 +529,9 @@ table.report th a {
|
||||
border-top: 1px solid #dddddd;
|
||||
padding: 8px;
|
||||
}
|
||||
.rendered-markdown :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* AJAX loader */
|
||||
.loading {
|
||||
|
||||
0
netbox/scripts/__init__.py
Normal file
0
netbox/scripts/__init__.py
Normal file
110
netbox/templates/extras/script.html
Normal file
110
netbox/templates/extras/script.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load log_levels %}
|
||||
|
||||
{% block title %}{{ script }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row noprint">
|
||||
<div class="col-md-12">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
|
||||
<li><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
|
||||
<li>{{ script }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<h1>{{ script }}</h1>
|
||||
<p>{{ script.Meta.description }}</p>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active">
|
||||
<a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
|
||||
</li>
|
||||
<li role="presentation"{% if not output %} class="disabled"{% endif %}>
|
||||
<a href="#output" role="tab" data-toggle="tab">Output</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#source" role="tab" data-toggle="tab">Source</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="run">
|
||||
{% if execution_time or script.log %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Script Log</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
{% for level, message in script.log %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{% log_level level %}</td>
|
||||
<td class="rendered-markdown">{{ message|gfm }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">
|
||||
No log output
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if execution_time %}
|
||||
<div class="panel-footer text-right text-muted">
|
||||
<small>Exec time: {{ execution_time|floatformat:3 }}s</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if not perms.extras.run_script %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-warning"></i>
|
||||
You do not have permission to run scripts.
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if not form.requires_input %}
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-exclamation-circle"></i>
|
||||
This script does not require any input to run.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Script Data</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pull-right">
|
||||
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="fa fa-play"></i> Run Script</button>
|
||||
<a href="{% url 'extras:script_list' %}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="output">
|
||||
<pre>{{ output }}</pre>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="source">
|
||||
<p><code>{{ script.filename }}</code></p>
|
||||
<pre>{{ script.source }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
38
netbox/templates/extras/script_list.html
Normal file
38
netbox/templates/extras/script_list.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Scripts{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% if scripts %}
|
||||
{% for module, module_scripts in scripts.items %}
|
||||
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
|
||||
<table class="table table-hover table-headings reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-md-3">Name</th>
|
||||
<th class="col-md-9">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for class_name, script in module_scripts.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
|
||||
</td>
|
||||
<td>{{ script.Meta.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<p><strong>No scripts found.</strong></p>
|
||||
<p>Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>. (This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.)</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1
netbox/templates/extras/templatetags/log_level.html
Normal file
1
netbox/templates/extras/templatetags/log_level.html
Normal file
@@ -0,0 +1 @@
|
||||
<label class="label label-{{ class }}">{{ name }}</label>
|
||||
@@ -66,6 +66,9 @@
|
||||
<li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:script_list' %}">Scripts</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:report_list' %}">Reports</a>
|
||||
</li>
|
||||
|
||||
5
netbox/utilities/exceptions.py
Normal file
5
netbox/utilities/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class AbortTransaction(Exception):
|
||||
"""
|
||||
A dummy exception used to trigger a database transaction rollback.
|
||||
"""
|
||||
pass
|
||||
Reference in New Issue
Block a user