From 1b8849a43b8e485c1857aeeb69b0da995c9c6d09 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 25 Jan 2024 15:18:58 -0800 Subject: [PATCH 01/32] 12510 move reports to use BaseScript --- netbox/extras/reports.py | 187 ++------------------------------------- netbox/extras/scripts.py | 147 ++++++++++++++++++++++++++---- 2 files changed, 134 insertions(+), 200 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 90641cc84a1..64f7d50a56f 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -11,6 +11,7 @@ from core.models import Job from .choices import LogLevelChoices from .models import ReportModule +from .scripts import BaseScript __all__ = ( 'Report', @@ -58,191 +59,15 @@ def run_report(job, *args, **kwargs): ) -class Report(object): - """ - NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each - report must have one or more test methods named `test_*`. - - The `_results` attribute of a completed report will take the following form: - - { - 'test_bar': { - 'failures': 42, - 'log': [ - (, , , ), - ... - ] - }, - 'test_foo': { - 'failures': 0, - 'log': [ - (, , , ), - ... - ] - } - } - """ - description = None - scheduling_enabled = True - job_timeout = None - - def __init__(self): - - self._results = {} - self.active_test = None - self.failed = False - - self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}") - - # Compile test methods and initialize results skeleton - test_methods = [] - for method in dir(self): - if method.startswith('test_') and callable(getattr(self, method)): - test_methods.append(method) - self._results[method] = { - 'success': 0, - 'info': 0, - 'warning': 0, - 'failure': 0, - 'log': [], - } - self.test_methods = test_methods - - @classproperty - def module(self): - return self.__module__ - - @classproperty - def class_name(self): - return self.__name__ - - @classproperty - def full_name(self): - return f'{self.module}.{self.class_name}' - - @property - def name(self): - """ - Override this attribute to set a custom display name. - """ - return self.class_name - - @property - def filename(self): - return inspect.getfile(self.__class__) - - @property - def source(self): - return inspect.getsource(self.__class__) - - @property - def is_valid(self): - """ - Indicates whether the report can be run. - """ - return bool(self.test_methods) - - # - # Logging methods - # - - def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT): - """ - Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. - """ - if level not in LogLevelChoices.values(): - raise Exception(f"Unknown logging level: {level}") - self._results[self.active_test]['log'].append(( - timezone.now().isoformat(), - level, - str(obj) if obj else None, - obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None, - message, - )) - - def log(self, message): - """ - Log a message which is not associated with a particular object. - """ - self._log(None, message, level=LogLevelChoices.LOG_DEFAULT) - self.logger.info(message) - +class Report(BaseScript): def log_success(self, obj, message=None): - """ - Record a successful test against an object. Logging a message is optional. - """ - if message: - self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS) - self._results[self.active_test]['success'] += 1 - self.logger.info(f"Success | {obj}: {message}") + super().log_success(message, obj) def log_info(self, obj, message): - """ - Log an informational message. - """ - self._log(obj, message, level=LogLevelChoices.LOG_INFO) - self._results[self.active_test]['info'] += 1 - self.logger.info(f"Info | {obj}: {message}") + super().log_info(message, obj) def log_warning(self, obj, message): - """ - Log a warning. - """ - self._log(obj, message, level=LogLevelChoices.LOG_WARNING) - self._results[self.active_test]['warning'] += 1 - self.logger.info(f"Warning | {obj}: {message}") + super().log_warning(message, obj) def log_failure(self, obj, message): - """ - Log a failure. Calling this method will automatically mark the report as failed. - """ - self._log(obj, message, level=LogLevelChoices.LOG_FAILURE) - self._results[self.active_test]['failure'] += 1 - self.logger.info(f"Failure | {obj}: {message}") - self.failed = True - - # - # Run methods - # - - def run(self, job): - """ - Run the report and save its results. Each test method will be executed in order. - """ - self.logger.info(f"Running report") - - # Perform any post-run tasks - self.pre_run() - - try: - for method_name in self.test_methods: - self.active_test = method_name - test_method = getattr(self, method_name) - test_method() - job.data = self._results - if self.failed: - self.logger.warning("Report failed") - job.terminate(status=JobStatusChoices.STATUS_FAILED) - else: - self.logger.info("Report completed successfully") - job.terminate() - except Exception as e: - stacktrace = traceback.format_exc() - self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}
{stacktrace}
") - logger.error(f"Exception raised during report execution: {e}") - job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e)) - - # Perform any post-run tasks - self.post_run() - - def pre_run(self): - """ - Extend this method to include any tasks which should execute *before* the report is run. - """ - pass - - def post_run(self): - """ - Extend this method to include any tasks which should execute *after* the report is run. - """ - pass + super().log_failure(message, obj) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index f28465547be..0994bc66f37 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -10,6 +10,7 @@ from django.conf import settings from django.core.validators import RegexValidator from django.db import transaction +from django.utils import timezone from django.utils.functional import classproperty from core.choices import JobStatusChoices @@ -257,7 +258,7 @@ def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwar # Scripts # -class BaseScript: +class BaseScript(object): """ Base model for custom scripts. User classes should inherit from this model if they want to extend Script functionality for use in other subclasses. @@ -270,6 +271,9 @@ class Meta: pass def __init__(self): + self._results = {} + self.failed = False + self._current_method = 'main' # Initiate the log self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}") @@ -278,9 +282,26 @@ def __init__(self): # Declare the placeholder for the current request self.request = None - # Grab some info about the script - self.filename = inspect.getfile(self.__class__) - self.source = inspect.getsource(self.__class__) + # Compile test methods and initialize results skeleton + self._results['main'] = { + 'success': 0, + 'info': 0, + 'warning': 0, + 'failure': 0, + 'log': [], + } + test_methods = [] + for method in dir(self): + if method.startswith('test_') and callable(getattr(self, method)): + test_methods.append(method) + self._results[method] = { + 'success': 0, + 'info': 0, + 'warning': 0, + 'failure': 0, + 'log': [], + } + self.test_methods = test_methods def __str__(self): return self.name @@ -331,6 +352,21 @@ def job_timeout(self): def scheduling_enabled(self): return getattr(self.Meta, 'scheduling_enabled', True) + @property + def filename(self): + return inspect.getfile(self.__class__) + + @property + def source(self): + return inspect.getsource(self.__class__) + + @property + def is_valid(self): + """ + Indicates whether the report can be run. + """ + return bool(self.test_methods) + @classmethod def _get_vars(cls): vars = {} @@ -399,25 +435,53 @@ def as_form(self, data=None, files=None, initial=None): # Logging - def log_debug(self, message): - self.logger.log(logging.DEBUG, message) - self.log.append((LogLevelChoices.LOG_DEFAULT, str(message))) + def _log(self, message, obj=None, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.INFO): + """ + Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. + """ + if log_level not in LogLevelChoices.values(): + raise Exception(f"Unknown logging level: {log_level}") + + if message: + self._results[self._current_method]['log'].append(( + timezone.now().isoformat(), + log_level, + str(obj) if obj else None, + obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None, + message, + )) + + if log_level != LogLevelChoices.LOG_DEFAULT: + self._results[self._current_method][log_level] += 1 + + if self._current_method != 'main': + self._results['main'][log_level] += 1 + + if obj: + self.logger.log(level, f"{log_level.capitalize()} | {obj}: {message}") + else: + self.logger.log(level, message) # No syslog equivalent for SUCCESS + + def log(self, message): + """ + Log a message which is not associated with a particular object. + """ + self._log(str(message), None, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.INFO) + + def log_debug(self, message, obj=None): + self._log(str(message), obj, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.DEBUG) - def log_success(self, message): - self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS - self.log.append((LogLevelChoices.LOG_SUCCESS, str(message))) + def log_success(self, message=None, obj=None): + self._log(str(message), obj, log_level=LogLevelChoices.LOG_SUCCESS, level=logging.INFO) - def log_info(self, message): - self.logger.log(logging.INFO, message) - self.log.append((LogLevelChoices.LOG_INFO, str(message))) + def log_info(self, message, obj=None): + self._log(str(message), obj, log_level=LogLevelChoices.LOG_INFO, level=logging.INFO) - def log_warning(self, message): - self.logger.log(logging.WARNING, message) - self.log.append((LogLevelChoices.LOG_WARNING, str(message))) + def log_warning(self, message, obj=None): + self._log(str(message), obj, log_level=LogLevelChoices.LOG_WARNING, level=logging.WARNING) - def log_failure(self, message): - self.logger.log(logging.ERROR, message) - self.log.append((LogLevelChoices.LOG_FAILURE, str(message))) + def log_failure(self, message, obj=None): + self._log(str(message), obj, log_level=LogLevelChoices.LOG_FAILURE, level=logging.ERROR) # Convenience functions @@ -446,6 +510,51 @@ def load_json(self, filename): return data + def run_test_scripts(self, job): + """ + Run the report and save its results. Each test method will be executed in order. + """ + self.logger.info(f"Running report") + + # Perform any post-run tasks + self.pre_run() + + try: + for method_name in self.test_methods: + self._current_method = method_name + test_method = getattr(self, method_name) + test_method() + job.data = self._results + if self.failed: + self.logger.warning("Report failed") + job.terminate(status=JobStatusChoices.STATUS_FAILED) + else: + self.logger.info("Report completed successfully") + job.terminate() + except Exception as e: + stacktrace = traceback.format_exc() + self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}
{stacktrace}
") + logger.error(f"Exception raised during report execution: {e}") + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e)) + + # Perform any post-run tasks + self.post_run() + + def run(self, job): + self.run_test_scripts(job) + + def pre_run(self): + """ + Extend this method to include any tasks which should execute *before* the report is run. + """ + pass + + def post_run(self): + """ + Extend this method to include any tasks which should execute *after* the report is run. + """ + pass + class Script(BaseScript): """ From c69205535ea6180bc12b60bcaefa8cec6632fdeb Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jan 2024 09:31:27 -0800 Subject: [PATCH 02/32] 12510 merge report into script view --- netbox/extras/models/scripts.py | 8 +++++--- netbox/extras/utils.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index 93275acdab0..55576421f5d 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -3,12 +3,13 @@ from functools import cached_property from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils.translation import gettext_lazy as _ from core.choices import ManagedFileRootPathChoices from core.models import ManagedFile -from extras.utils import is_script +from extras.utils import is_script_or_report from netbox.models.features import JobsMixin, EventRulesMixin from utilities.querysets import RestrictedQuerySet from .mixins import PythonModuleMixin @@ -32,7 +33,8 @@ class Meta: class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)): def get_queryset(self): - return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS) + return super().get_queryset().filter( + Q(file_root=ManagedFileRootPathChoices.SCRIPTS) | Q(file_root=ManagedFileRootPathChoices.REPORTS)) class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): @@ -70,7 +72,7 @@ def _get_name(cls): for cls in ordered: scripts[_get_name(cls)] = cls - for name, cls in inspect.getmembers(module, is_script): + for name, cls in inspect.getmembers(module, is_script_or_report): if cls not in ordered: scripts[_get_name(cls)] = cls diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index c6b2de18838..563770f73b3 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -67,3 +67,15 @@ def is_report(obj): return issubclass(obj, Report) and obj != Report except TypeError: return False + + +def is_script_or_report(obj): + """ + Returns True if the given object is a Report. + """ + from .reports import Report + from .scripts import Script + try: + return ((issubclass(obj, Report) and obj != Report) or (issubclass(obj, Script) and obj != Script)) + except TypeError: + return False From ab14a8a71c78e76db29f2520eeec6ee079c280f3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jan 2024 10:20:42 -0800 Subject: [PATCH 03/32] 12510 add migration for job report to script --- .../migrations/0011_job_report_to_script.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 netbox/core/migrations/0011_job_report_to_script.py diff --git a/netbox/core/migrations/0011_job_report_to_script.py b/netbox/core/migrations/0011_job_report_to_script.py new file mode 100644 index 00000000000..4273475ef50 --- /dev/null +++ b/netbox/core/migrations/0011_job_report_to_script.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.1 on 2024-01-26 18:11 + +from django.db import migrations + + +def migrate_report_jobs(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + Job = apps.get_model('core', 'Job') + + # Delete the new ContentType effected by the introduction of core.ConfigRevision + report_content_type = ContentType.objects.get(app_label='extras', model='reportmodule') + script_content_type = ContentType.objects.get(app_label='extras', model='scriptmodule') + jobs = Job.objects.filter(object_type_id=report_content_type.id).update(object_type_id=script_content_type.id) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_gfk_indexes'), + ] + + operations = [ + migrations.RunPython( + code=migrate_report_jobs, + reverse_code=migrations.RunPython.noop + ), + ] From 8a990d7372544c6ce21403a2512cbee6a48e31a3 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jan 2024 10:36:34 -0800 Subject: [PATCH 04/32] 12510 update templates --- netbox/extras/scripts.py | 8 +- .../templates/extras/htmx/script_result.html | 77 ++++++++----- netbox/templates/extras/script_list.html | 104 ++++++++++++------ 3 files changed, 121 insertions(+), 68 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 0994bc66f37..6aed2d03241 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -273,7 +273,7 @@ class Meta: def __init__(self): self._results = {} self.failed = False - self._current_method = 'main' + self._current_method = 'total' # Initiate the log self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}") @@ -283,7 +283,7 @@ def __init__(self): self.request = None # Compile test methods and initialize results skeleton - self._results['main'] = { + self._results['total'] = { 'success': 0, 'info': 0, 'warning': 0, @@ -454,8 +454,8 @@ def _log(self, message, obj=None, log_level=LogLevelChoices.LOG_DEFAULT, level=l if log_level != LogLevelChoices.LOG_DEFAULT: self._results[self._current_method][log_level] += 1 - if self._current_method != 'main': - self._results['main'][log_level] += 1 + if self._current_method != 'total': + self._results['total'][log_level] += 1 if obj: self.logger.log(level, f"{log_level.capitalize()} | {obj}: {message}") diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 2fb0de46809..f79fb8e483e 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -1,6 +1,5 @@ {% load humanize %} {% load helpers %} -{% load log_levels %} {% load i18n %}

@@ -17,40 +16,62 @@ {% badge job.get_status_display job.get_status_color %}

{% if job.completed %} -
-
{% trans "Script Log" %}
+
+
{% trans "Script Methods" %}
- - - - - - {% for log in job.data.log %} + {% for method, data in job.data.items %} - - - - - {% empty %} - - + {% endfor %}
{% trans "Line" %}{% trans "Level" %}{% trans "Message" %}
{{ forloop.counter }}{% log_level log.status %}{{ log.message|markdown }}
- {% trans "No log output" %} + {{ method }} + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }}
- {% if execution_time %} - - {% endif %}
-

{% trans "Output" %}

- {% if job.data.output %} -
{{ job.data.output }}
- {% else %} -

{% trans "None" %}

- {% endif %} +
+
{% trans "Script Results" %}
+ + + + + + + + + + + {% for method, data in job.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} + +
{% trans "Time" %}{% trans "Level" %}{% trans "Object" %}{% trans "Message" %}
+ {{ method }} +
{{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {{ message|markdown }}
+
{% elif job.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index bb91f75222b..afc4e588825 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -1,14 +1,11 @@ {% extends 'generic/_base.html' %} {% load buttons %} {% load helpers %} +{% load perms %} {% load i18n %} {% block title %}{% trans "Scripts" %}{% endblock %} -{% block controls %} - {% add_button model %} -{% endblock controls %} - {% block tabs %} {% endblock tabs %} +{% block controls %} + {% add_button model %} +{% endblock controls %} + {% block content %} {% for module in script_modules %}
@@ -25,65 +26,96 @@
{{ module }}
{% if perms.extras.delete_scriptmodule %} - + + {% trans "Delete" %} + {% endif %}
{% include 'inc/sync_warning.html' with object=module %} - {% if not module.scripts %} - - {% else %} - + {% if module.scripts %} +
- + + {% with jobs=module.get_latest_jobs %} - {% for script_name, script_class in module.scripts.items %} - - - - {% with last_result=jobs|get_key:script_class.class_name %} - {% if last_result %} + {% for script_name, script in module.scripts.items %} + {% with last_job=jobs|get_key:script.class_name %} + + + + {% if last_job %} - {% else %} - + {% endif %} - {% endwith %} - + + + {% for method, stats in last_job.data.items %} + + + + + {% endfor %} + {% endwith %} {% endfor %} {% endwith %}
{% trans "Name" %} {% trans "Description" %} {% trans "Last Run" %}{% trans "Status" %}{% trans "Status" %}
- {{ script_class.name }} - - {{ script_class.Meta.description|markdown|placeholder }} -
+ {{ script.name }} + {{ script.description|markdown|placeholder }} - {{ last_result.created|annotated_date }} + {{ last_job.created|annotated_date }} - {% badge last_result.get_status_display last_result.get_status_color %} + + {% badge last_job.get_status_display last_job.get_status_color %} {% trans "Never" %}{{ ''|placeholder }} + {% if script.is_valid %} + {{ ''|placeholder }} + {% else %} + + {% trans "Invalid" %} + + {% endif %} +
+ {% if perms.extras.run_script and script.is_valid %} +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
+ {% else %} + {% endif %}
{% empty %} -
+