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/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
!/netbox/scripts/__init__.py
|
||||
/netbox/static
|
||||
.idea
|
||||
/*.sh
|
||||
|
@ -380,3 +380,18 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
widget=ContentTypeSelect(),
|
||||
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>/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'),
|
||||
|
||||
]
|
||||
|
@ -1,8 +1,9 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
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.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404
|
||||
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 .reports import get_report, get_reports
|
||||
from .scripts import get_scripts
|
||||
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
||||
|
||||
|
||||
@ -355,3 +357,53 @@ class ReportRunView(PermissionRequiredMixin, View):
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
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)
|
||||
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')
|
||||
|
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