diff --git a/.gitignore b/.gitignore index d859bad28..36c6d3fa8 100644 --- a/.gitignore +++ b/.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 diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 261822d28..fad5a7ac2 100644 --- a/netbox/extras/forms.py +++ b/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() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py new file mode 100644 index 000000000..7ba7edbf0 --- /dev/null +++ b/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 diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py new file mode 100644 index 000000000..f1a545cb9 --- /dev/null +++ b/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] diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ad6eabe1e..7de0faf91 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -28,13 +28,17 @@ urlpatterns = [ path(r'image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path(r'image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Change logging + path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + # Reports path(r'reports/', views.ReportListView.as_view(), name='report_list'), path(r'reports//', views.ReportView.as_view(), name='report'), path(r'reports//run/', views.ReportRunView.as_view(), name='report_run'), - # Change logging - path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), - path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), + # Scripts + path(r'scripts/', views.ScriptListView.as_view(), name='script_list'), + path(r'scripts///', views.ScriptView.as_view(), name='script'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6f4751619..8f9f2d282 100644 --- a/netbox/extras/views.py +++ b/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, + }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 090122e37..014b623cd 100644 --- a/netbox/netbox/settings.py +++ b/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') diff --git a/netbox/scripts/__init__.py b/netbox/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html new file mode 100644 index 000000000..8b4065613 --- /dev/null +++ b/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 %} +
+
+ +
+
+

{{ script }}

+

{{ script.description }}

+ +
+
+ {% if script.log %} +
+
+
+
+ Script Output +
+ + + + + + + {% for level, message in script.log %} + + + + + + {% endfor %} +
LineLevelMessage
{{ forloop.counter }}{% log_level level %}{{ message }}
+
+
+
+ {% endif %} +
+
+
+ {% csrf_token %} + {% if form %} + {% render_form form %} + {% else %} +

This script does not require any input to run.

+ {% endif %} +
+ + Cancel +
+
+
+
+
+
+ {{ script.filename }} +
{{ script.source }}
+
+
+{% endblock %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html new file mode 100644 index 000000000..0189ef755 --- /dev/null +++ b/netbox/templates/extras/script_list.html @@ -0,0 +1,40 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

{% block title %}Scripts{% endblock %}

+
+
+ {% if scripts %} + {% for module, module_scripts in scripts.items %} +

{{ module|bettertitle }}

+ + + + + + + + + + {% for class_name, script in module_scripts.items %} + + + + + + {% endfor %} + +
NameDescription
+ {{ script }} + {{ script.description }}
+ {% endfor %} + {% else %} +
+

No scripts found.

+

Reports should be saved to {{ settings.SCRIPTS_ROOT }}. (This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration.)

+
+ {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/templatetags/log_level.html b/netbox/templates/extras/templatetags/log_level.html new file mode 100644 index 000000000..0787c2d46 --- /dev/null +++ b/netbox/templates/extras/templatetags/log_level.html @@ -0,0 +1 @@ + \ No newline at end of file