Ver código fonte

Initial work on custom scripts (#3415)

Jeremy Stretch 6 anos atrás
pai
commit
a25a27f31f

+ 2 - 0
.gitignore

@@ -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

+ 15 - 0
netbox/extras/forms.py

@@ -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 - 0
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 - 0
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]

+ 7 - 3
netbox/extras/urls.py

@@ -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'),
 
 ]

+ 53 - 1
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
@@ -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,
+        })

+ 1 - 0
netbox/netbox/settings.py

@@ -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 - 0
netbox/scripts/__init__.py


+ 77 - 0
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 %}

+ 40 - 0
netbox/templates/extras/script_list.html

@@ -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 - 0
netbox/templates/extras/templatetags/log_level.html

@@ -0,0 +1 @@
+<label class="label label-{{ class }}">{{ name }}</label>