Skip to content

Commit

Permalink
Initial work on custom scripts (#3415)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Aug 9, 2019
1 parent 15b55f5 commit 13fa179
Show file tree
Hide file tree
Showing 11 changed files with 376 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions netbox/extras/forms.py
Expand Up @@ -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 changes: 143 additions & 0 deletions netbox/extras/scripts.py
@@ -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 changes: 37 additions & 0 deletions netbox/extras/templatetags/log_levels.py
@@ -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]
10 changes: 7 additions & 3 deletions netbox/extras/urls.py
Expand Up @@ -28,13 +28,17 @@
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'),

]
54 changes: 53 additions & 1 deletion netbox/extras/views.py
@@ -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
Expand All @@ -20,6 +21,7 @@
)
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


Expand Down Expand Up @@ -355,3 +357,53 @@ def post(self, request, name):
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,
})
1 change: 1 addition & 0 deletions netbox/netbox/settings.py
Expand Up @@ -85,6 +85,7 @@
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')
Expand Down
Empty file added netbox/scripts/__init__.py
Empty file.
77 changes: 77 additions & 0 deletions netbox/templates/extras/script.html
@@ -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 %}

0 comments on commit 13fa179

Please sign in to comment.