mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Initial work on custom scripts (#3415)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,6 +3,8 @@
|
|||||||
/netbox/netbox/ldap_config.py
|
/netbox/netbox/ldap_config.py
|
||||||
/netbox/reports/*
|
/netbox/reports/*
|
||||||
!/netbox/reports/__init__.py
|
!/netbox/reports/__init__.py
|
||||||
|
/netbox/scripts/*
|
||||||
|
!/netbox/scripts/__init__.py
|
||||||
/netbox/static
|
/netbox/static
|
||||||
.idea
|
.idea
|
||||||
/*.sh
|
/*.sh
|
||||||
|
@ -380,3 +380,18 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
|||||||
widget=ContentTypeSelect(),
|
widget=ContentTypeSelect(),
|
||||||
label='Object Type'
|
label='Object Type'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Scripts
|
||||||
|
#
|
||||||
|
|
||||||
|
class ScriptForm(BootstrapMixin, forms.Form):
|
||||||
|
|
||||||
|
def __init__(self, vars, *args, **kwargs):
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Dynamically populate fields for variables
|
||||||
|
for name, var in vars:
|
||||||
|
self.fields[name] = var.as_field()
|
||||||
|
143
netbox/extras/scripts.py
Normal file
143
netbox/extras/scripts.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
import inspect
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||||
|
from .forms import ScriptForm
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Script variables
|
||||||
|
#
|
||||||
|
|
||||||
|
class ScriptVariable:
|
||||||
|
form_field = forms.CharField
|
||||||
|
|
||||||
|
def __init__(self, label='', description=''):
|
||||||
|
|
||||||
|
# Default field attributes
|
||||||
|
if not hasattr(self, 'field_attrs'):
|
||||||
|
self.field_attrs = {}
|
||||||
|
if label:
|
||||||
|
self.field_attrs['label'] = label
|
||||||
|
if description:
|
||||||
|
self.field_attrs['help_text'] = description
|
||||||
|
|
||||||
|
def as_field(self):
|
||||||
|
"""
|
||||||
|
Render the variable as a Django form field.
|
||||||
|
"""
|
||||||
|
return self.form_field(**self.field_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
class StringVar(ScriptVariable):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegerVar(ScriptVariable):
|
||||||
|
form_field = forms.IntegerField
|
||||||
|
|
||||||
|
|
||||||
|
class BooleanVar(ScriptVariable):
|
||||||
|
form_field = forms.BooleanField
|
||||||
|
field_attrs = {
|
||||||
|
'required': False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectVar(ScriptVariable):
|
||||||
|
form_field = forms.ModelChoiceField
|
||||||
|
|
||||||
|
def __init__(self, queryset, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.field_attrs['queryset'] = queryset
|
||||||
|
|
||||||
|
|
||||||
|
class Script:
|
||||||
|
"""
|
||||||
|
Custom scripts inherit this object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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):
|
||||||
|
if hasattr(self, 'name'):
|
||||||
|
return self.name
|
||||||
|
return self.__class__.__name__
|
||||||
|
|
||||||
|
def _get_vars(self):
|
||||||
|
# TODO: This should preserve var ordering
|
||||||
|
return inspect.getmembers(self, is_variable)
|
||||||
|
|
||||||
|
def run(self, context):
|
||||||
|
raise NotImplementedError("The script must define a run() method.")
|
||||||
|
|
||||||
|
def as_form(self, data=None):
|
||||||
|
"""
|
||||||
|
Return a Django form suitable for populating the context data required to run this Script.
|
||||||
|
"""
|
||||||
|
vars = self._get_vars()
|
||||||
|
form = ScriptForm(vars, data)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Functions
|
||||||
|
#
|
||||||
|
|
||||||
|
def is_script(obj):
|
||||||
|
"""
|
||||||
|
Returns True if the object is a Script.
|
||||||
|
"""
|
||||||
|
return obj in Script.__subclasses__()
|
||||||
|
|
||||||
|
|
||||||
|
def is_variable(obj):
|
||||||
|
"""
|
||||||
|
Returns True if the object is a ScriptVariable.
|
||||||
|
"""
|
||||||
|
return isinstance(obj, ScriptVariable)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
module_scripts = OrderedDict()
|
||||||
|
for name, cls in inspect.getmembers(module, is_script):
|
||||||
|
module_scripts[name] = cls
|
||||||
|
scripts[module_name] = module_scripts
|
||||||
|
|
||||||
|
return scripts
|
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]
|
@ -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>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||||
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
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
|
# Reports
|
||||||
path(r'reports/', views.ReportListView.as_view(), name='report_list'),
|
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>/', views.ReportView.as_view(), name='report'),
|
||||||
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
|
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
|
||||||
|
|
||||||
# Change logging
|
# Scripts
|
||||||
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||||
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
|
path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@ -20,6 +21,7 @@ from .forms import (
|
|||||||
)
|
)
|
||||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||||
from .reports import get_report, get_reports
|
from .reports import get_report, get_reports
|
||||||
|
from .scripts import get_scripts
|
||||||
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
||||||
|
|
||||||
|
|
||||||
@ -355,3 +357,53 @@ class ReportRunView(PermissionRequiredMixin, View):
|
|||||||
messages.success(request, mark_safe(msg))
|
messages.success(request, mark_safe(msg))
|
||||||
|
|
||||||
return redirect('extras:report', name=report.full_name)
|
return redirect('extras:report', name=report.full_name)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Scripts
|
||||||
|
#
|
||||||
|
|
||||||
|
class ScriptListView(LoginRequiredMixin, View):
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
|
||||||
|
return render(request, 'extras/script_list.html', {
|
||||||
|
'scripts': get_scripts(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptView(LoginRequiredMixin, View):
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
script = self._get_script(module, name)
|
||||||
|
form = script.as_form(request.POST)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
script.run(form.cleaned_data)
|
||||||
|
|
||||||
|
return render(request, 'extras/script.html', {
|
||||||
|
'module': module,
|
||||||
|
'script': script,
|
||||||
|
'form': form,
|
||||||
|
})
|
||||||
|
@ -85,6 +85,7 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
|||||||
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
||||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
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)
|
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
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_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||||
|
0
netbox/scripts/__init__.py
Normal file
0
netbox/scripts/__init__.py
Normal file
77
netbox/templates/extras/script.html
Normal file
77
netbox/templates/extras/script.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{% 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.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">
|
||||||
|
<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 script.log %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<strong>Script Output</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>{{ message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 col-md-offset-2">
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form %}
|
||||||
|
{% render_form form %}
|
||||||
|
{% else %}
|
||||||
|
<p>This script does not require any input to run.</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pull-right">
|
||||||
|
<button type="submit" name="_run" class="btn btn-primary"><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="source">
|
||||||
|
<strong>{{ script.filename }}</strong>
|
||||||
|
<pre>{{ script.source }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
40
netbox/templates/extras/script_list.html
Normal file
40
netbox/templates/extras/script_list.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{% extends '_base.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% block title %}Scripts{% endblock %}</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9">
|
||||||
|
{% 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>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th></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.description }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p><strong>No scripts found.</strong></p>
|
||||||
|
<p>Reports 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>
|
Reference in New Issue
Block a user