From f7a2eb8aef53d3ec2e4bf55c2199fd59cce76ea2 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 24 Mar 2023 21:00:36 -0400
Subject: [PATCH] Closes #11890: Sync/upload reports & scripts (#12059)
* Initial work on #11890
* Consolidate get_scripts() and get_reports() functions
* Introduce proxy models for script & report modules
* Add add/delete views for reports & scripts
* Add deletion links for modules
* Enable resolving scripts/reports from module class
* Remove get_modules() utility function
* Show results in report/script lists
* Misc cleanup
* Fix file uploads
* Support automatic migration for submodules
* Fix module child ordering
* Template cleanup
* Remove ManagedFile views
* Move is_script(), is_report() into extras.utils
* Fix URLs for nested reports & scripts
* Misc cleanup
---
netbox/core/choices.py | 15 ++-
netbox/core/forms/model_forms.py | 36 ++++++
netbox/core/migrations/0002_managedfile.py | 39 ++++++
netbox/core/models/__init__.py | 1 +
netbox/core/models/data.py | 14 ++-
netbox/core/models/files.py | 88 +++++++++++++
netbox/extras/api/views.py | 28 ++---
.../extras/management/commands/runreport.py | 14 +--
.../migrations/0091_create_managedfiles.py | 86 +++++++++++++
netbox/extras/models/__init__.py | 2 +
netbox/extras/models/models.py | 119 +++++++++++++++++-
netbox/extras/reports.py | 84 +++----------
netbox/extras/scripts.py | 93 +++-----------
netbox/extras/urls.py | 16 ++-
netbox/extras/utils.py | 22 ++++
netbox/extras/views.py | 108 +++++++++-------
.../templates/extras/htmx/report_result.html | 2 +-
.../templates/extras/htmx/script_result.html | 2 +-
netbox/templates/extras/inc/job_label.html | 15 ---
netbox/templates/extras/report.html | 2 +-
netbox/templates/extras/report_list.html | 99 +++++++++------
netbox/templates/extras/script.html | 2 +-
netbox/templates/extras/script_list.html | 86 ++++++++-----
23 files changed, 658 insertions(+), 315 deletions(-)
create mode 100644 netbox/core/migrations/0002_managedfile.py
create mode 100644 netbox/core/models/files.py
create mode 100644 netbox/extras/migrations/0091_create_managedfiles.py
delete mode 100644 netbox/templates/extras/inc/job_label.html
diff --git a/netbox/core/choices.py b/netbox/core/choices.py
index be33afabae2..2b3d90a4284 100644
--- a/netbox/core/choices.py
+++ b/netbox/core/choices.py
@@ -20,7 +20,6 @@ class DataSourceTypeChoices(ChoiceSet):
class DataSourceStatusChoices(ChoiceSet):
-
NEW = 'new'
QUEUED = 'queued'
SYNCING = 'syncing'
@@ -34,3 +33,17 @@ class DataSourceStatusChoices(ChoiceSet):
(COMPLETED, _('Completed'), 'green'),
(FAILED, _('Failed'), 'red'),
)
+
+
+#
+# Managed files
+#
+
+class ManagedFileRootPathChoices(ChoiceSet):
+ SCRIPTS = 'scripts' # settings.SCRIPTS_ROOT
+ REPORTS = 'reports' # settings.REPORTS_ROOT
+
+ CHOICES = (
+ (SCRIPTS, _('Scripts')),
+ (REPORTS, _('Reports')),
+ )
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index a3a478be5ef..99e2786efcd 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -3,12 +3,14 @@
from django import forms
from core.models import *
+from extras.forms.mixins import SyncedDataMixin
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
from utilities.forms import CommentField, get_field_value
__all__ = (
'DataSourceForm',
+ 'ManagedFileForm',
)
@@ -73,3 +75,37 @@ def save(self, *args, **kwargs):
self.instance.parameters = parameters
return super().save(*args, **kwargs)
+
+
+class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
+ upload_file = forms.FileField(
+ required=False
+ )
+
+ fieldsets = (
+ ('File Upload', ('upload_file',)),
+ ('Data Source', ('data_source', 'data_file')),
+ )
+
+ class Meta:
+ model = ManagedFile
+ fields = ('data_source', 'data_file')
+
+ def clean(self):
+ super().clean()
+
+ if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
+ raise forms.ValidationError("Cannot upload a file and sync from an existing file")
+ if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
+ raise forms.ValidationError("Must upload a file or select a data file to sync")
+
+ return self.cleaned_data
+
+ def save(self, *args, **kwargs):
+ # If a file was uploaded, save it to disk
+ if self.cleaned_data['upload_file']:
+ self.instance.file_path = self.cleaned_data['upload_file'].name
+ with open(self.instance.full_path, 'wb+') as new_file:
+ new_file.write(self.cleaned_data['upload_file'].read())
+
+ return super().save(*args, **kwargs)
diff --git a/netbox/core/migrations/0002_managedfile.py b/netbox/core/migrations/0002_managedfile.py
new file mode 100644
index 00000000000..da6b1e3be98
--- /dev/null
+++ b/netbox/core/migrations/0002_managedfile.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.1.7 on 2023-03-23 17:35
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ManagedFile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('data_path', models.CharField(blank=True, editable=False, max_length=1000)),
+ ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('last_updated', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('file_root', models.CharField(max_length=1000)),
+ ('file_path', models.FilePathField(editable=False)),
+ ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
+ ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
+ ],
+ options={
+ 'ordering': ('file_root', 'file_path'),
+ },
+ ),
+ migrations.AddIndex(
+ model_name='managedfile',
+ index=models.Index(fields=['file_root', 'file_path'], name='core_managedfile_root_path'),
+ ),
+ migrations.AddConstraint(
+ model_name='managedfile',
+ constraint=models.UniqueConstraint(fields=('file_root', 'file_path'), name='core_managedfile_unique_root_path'),
+ ),
+ ]
diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py
index df22d8bbb44..e21b9f8cf43 100644
--- a/netbox/core/models/__init__.py
+++ b/netbox/core/models/__init__.py
@@ -1 +1,2 @@
from .data import *
+from .files import *
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 8d716404706..cbf29ae3833 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -14,7 +14,6 @@
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
-from extras.models import JobResult
from netbox.models import PrimaryModel
from netbox.registry import registry
from utilities.files import sha256_hash
@@ -113,6 +112,8 @@ def enqueue_sync_job(self, request):
"""
Enqueue a background job to synchronize the DataSource by calling sync().
"""
+ from extras.models import JobResult
+
# Set the status to "syncing"
self.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=self.pk).update(status=self.status)
@@ -314,3 +315,14 @@ def refresh_from_disk(self, source_root):
self.data = f.read()
return is_modified
+
+ def write_to_disk(self, path, overwrite=False):
+ """
+ Write the object's data to disk at the specified path
+ """
+ # Check whether file already exists
+ if os.path.isfile(path) and not overwrite:
+ raise FileExistsError()
+
+ with open(path, 'wb+') as new_file:
+ new_file.write(self.data)
diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py
new file mode 100644
index 00000000000..a725ea0ac28
--- /dev/null
+++ b/netbox/core/models/files.py
@@ -0,0 +1,88 @@
+import logging
+import os
+
+from django.conf import settings
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext as _
+
+from ..choices import ManagedFileRootPathChoices
+from netbox.models.features import SyncedDataMixin
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+ 'ManagedFile',
+)
+
+logger = logging.getLogger('netbox.core.files')
+
+
+class ManagedFile(SyncedDataMixin, models.Model):
+ """
+ Database representation for a file on disk. This class is typically wrapped by a proxy class (e.g. ScriptModule)
+ to provide additional functionality.
+ """
+ created = models.DateTimeField(
+ auto_now_add=True
+ )
+ last_updated = models.DateTimeField(
+ editable=False,
+ blank=True,
+ null=True
+ )
+ file_root = models.CharField(
+ max_length=1000,
+ choices=ManagedFileRootPathChoices
+ )
+ file_path = models.FilePathField(
+ editable=False,
+ help_text=_("File path relative to the designated root path")
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ class Meta:
+ ordering = ('file_root', 'file_path')
+ constraints = (
+ models.UniqueConstraint(
+ fields=('file_root', 'file_path'),
+ name='%(app_label)s_%(class)s_unique_root_path'
+ ),
+ )
+ indexes = [
+ models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'),
+ ]
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('core:managedfile', args=[self.pk])
+
+ @property
+ def name(self):
+ return self.file_path
+
+ @property
+ def full_path(self):
+ return os.path.join(self._resolve_root_path(), self.file_path)
+
+ def _resolve_root_path(self):
+ return {
+ 'scripts': settings.SCRIPTS_ROOT,
+ 'reports': settings.REPORTS_ROOT,
+ }[self.file_root]
+
+ def sync_data(self):
+ if self.data_file:
+ self.file_path = os.path.basename(self.data_path)
+ self.data_file.write_to_disk(self.full_path, overwrite=True)
+
+ def delete(self, *args, **kwargs):
+ # Delete file from disk
+ try:
+ os.remove(self.full_path)
+ except FileNotFoundError:
+ pass
+
+ return super().delete(*args, **kwargs)
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index dfee44a51a8..d09c88abe3d 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -16,8 +16,8 @@
from extras.choices import JobResultStatusChoices
from extras.models import *
from extras.models import CustomField
-from extras.reports import get_report, get_reports, run_report
-from extras.scripts import get_script, get_scripts, run_script
+from extras.reports import get_report, run_report
+from extras.scripts import get_script, run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
@@ -27,7 +27,6 @@
from utilities.utils import copy_safe_request, count_related
from . import serializers
from .mixins import ConfigTemplateRenderMixin
-from .nested_serializers import NestedConfigTemplateSerializer
class ExtrasRootView(APIRootView):
@@ -189,7 +188,6 @@ def list(self, request):
"""
Compile all reports and their related results (if any). Result data is deferred in the list view.
"""
- report_list = []
report_content_type = ContentType.objects.get(app_label='extras', model='report')
results = {
r.name: r
@@ -199,13 +197,13 @@ def list(self, request):
).order_by('name', '-created').distinct('name').defer('data')
}
- # Iterate through all available Reports.
- for module_name, reports in get_reports().items():
- for report in reports.values():
+ report_list = []
+ for report_module in ReportModule.objects.restrict(request.user):
+ report_list.extend([report() for report in report_module.reports.values()])
- # Attach the relevant JobResult (if any) to each Report.
- report.result = results.get(report.full_name, None)
- report_list.append(report)
+ # Attach JobResult objects to each report (if any)
+ for report in report_list:
+ report.result = results.get(report.full_name, None)
serializer = serializers.ReportSerializer(report_list, many=True, context={
'request': request,
@@ -296,15 +294,15 @@ def list(self, request):
).order_by('name', '-created').distinct('name').defer('data')
}
- flat_list = []
- for script_list in get_scripts().values():
- flat_list.extend(script_list.values())
+ script_list = []
+ for script_module in ScriptModule.objects.restrict(request.user):
+ script_list.extend(script_module.scripts.values())
# Attach JobResult objects to each script (if any)
- for script in flat_list:
+ for script in script_list:
script.result = results.get(script.full_name, None)
- serializer = serializers.ScriptSerializer(flat_list, many=True, context={'request': request})
+ serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
return Response(serializer.data)
diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py
index 38d43561337..9a61e780524 100644
--- a/netbox/extras/management/commands/runreport.py
+++ b/netbox/extras/management/commands/runreport.py
@@ -5,8 +5,8 @@
from django.utils import timezone
from extras.choices import JobResultStatusChoices
-from extras.models import JobResult
-from extras.reports import get_reports, run_report
+from extras.models import JobResult, ReportModule
+from extras.reports import run_report
class Command(BaseCommand):
@@ -17,13 +17,9 @@ def add_arguments(self, parser):
def handle(self, *args, **options):
- # Gather all available reports
- reports = get_reports()
-
- # Run reports
- for module_name, report_list in reports.items():
- for report in report_list.values():
- if module_name in options['reports'] or report.full_name in options['reports']:
+ for module in ReportModule.objects.all():
+ for report in module.reports.values():
+ if module.name in options['reports'] or report.full_name in options['reports']:
# Run the report and create a new JobResult
self.stdout.write(
diff --git a/netbox/extras/migrations/0091_create_managedfiles.py b/netbox/extras/migrations/0091_create_managedfiles.py
new file mode 100644
index 00000000000..71581614ae9
--- /dev/null
+++ b/netbox/extras/migrations/0091_create_managedfiles.py
@@ -0,0 +1,86 @@
+import os
+import pkgutil
+
+from django.conf import settings
+from django.db import migrations, models
+import extras.models.models
+
+
+def create_files(cls, root_name, root_path):
+
+ path_tree = [
+ path for path, _, _ in os.walk(root_path)
+ if os.path.basename(path)[0] not in ('_', '.')
+ ]
+
+ modules = list(pkgutil.iter_modules(path_tree))
+ filenames = []
+ for importer, module_name, is_pkg in modules:
+ if is_pkg:
+ continue
+ try:
+ module = importer.find_module(module_name).load_module(module_name)
+ rel_path = os.path.relpath(module.__file__, root_path)
+ filenames.append(rel_path)
+ except ImportError:
+ pass
+
+ managed_files = [
+ cls(file_root=root_name, file_path=filename)
+ for filename in filenames
+ ]
+ cls.objects.bulk_create(managed_files)
+
+
+def replicate_scripts(apps, schema_editor):
+ ScriptModule = apps.get_model('extras', 'ScriptModule')
+ create_files(ScriptModule, 'scripts', settings.SCRIPTS_ROOT)
+
+
+def replicate_reports(apps, schema_editor):
+ ReportModule = apps.get_model('extras', 'ReportModule')
+ create_files(ReportModule, 'reports', settings.REPORTS_ROOT)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0002_managedfile'),
+ ('extras', '0090_objectchange_index_request_id'),
+ ]
+
+ operations = [
+ # Create proxy models
+ migrations.CreateModel(
+ name='ReportModule',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=(extras.models.models.PythonModuleMixin, 'core.managedfile', models.Model),
+ ),
+ migrations.CreateModel(
+ name='ScriptModule',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=(extras.models.models.PythonModuleMixin, 'core.managedfile', models.Model),
+ ),
+
+ # Instantiate ManagedFiles to represent scripts & reports
+ migrations.RunPython(
+ code=replicate_scripts,
+ reverse_code=migrations.RunPython.noop
+ ),
+ migrations.RunPython(
+ code=replicate_reports,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index 14e23366fdc..b89b958f5e4 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -23,8 +23,10 @@
'JournalEntry',
'ObjectChange',
'Report',
+ 'ReportModule',
'SavedFilter',
'Script',
+ 'ScriptModule',
'StagedChange',
'Tag',
'TaggedItem',
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index b0c6d7fe367..f1e70190b4f 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -1,6 +1,11 @@
+import inspect
import json
+import os
import uuid
+from functools import cached_property
+from pkgutil import ModuleInfo, get_importer
+import django_rq
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
@@ -16,12 +21,13 @@
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from rest_framework.utils.encoders import JSONEncoder
-import django_rq
+from core.choices import ManagedFileRootPathChoices
+from core.models import ManagedFile
from extras.choices import *
-from extras.constants import *
from extras.conditions import ConditionSet
-from extras.utils import FeatureQuery, image_upload
+from extras.constants import *
+from extras.utils import FeatureQuery, image_upload, is_report, is_script
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models import ChangeLoggedModel
@@ -41,8 +47,10 @@
'JobResult',
'JournalEntry',
'Report',
+ 'ReportModule',
'SavedFilter',
'Script',
+ 'ScriptModule',
'Webhook',
)
@@ -814,6 +822,27 @@ def is_active(self):
# Custom scripts & reports
#
+class PythonModuleMixin:
+
+ @property
+ def path(self):
+ return os.path.splitext(self.file_path)[0]
+
+ def get_module_info(self):
+ path = os.path.dirname(self.full_path)
+ module_name = os.path.basename(self.path)
+ return ModuleInfo(
+ module_finder=get_importer(path),
+ name=module_name,
+ ispkg=False
+ )
+
+ def get_module(self):
+ importer, module_name, _ = self.get_module_info()
+ module = importer.find_module(module_name).load_module(module_name)
+ return module
+
+
class Script(JobResultsMixin, WebhooksMixin, models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
@@ -822,6 +851,48 @@ class Meta:
managed = False
+class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
+
+ def get_queryset(self):
+ return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
+
+
+class ScriptModule(PythonModuleMixin, ManagedFile):
+ """
+ Proxy model for script module files.
+ """
+ objects = ScriptModuleManager()
+
+ class Meta:
+ proxy = True
+
+ def get_absolute_url(self):
+ return reverse('extras:script_list')
+
+ @cached_property
+ def scripts(self):
+
+ def _get_name(cls):
+ # For child objects in submodules use the full import path w/o the root module as the name
+ return cls.full_name.split(".", maxsplit=1)[1]
+
+ module = self.get_module()
+ scripts = {}
+ ordered = getattr(module, 'script_order', [])
+
+ for cls in ordered:
+ scripts[_get_name(cls)] = cls
+ for name, cls in inspect.getmembers(module, is_script):
+ if cls not in ordered:
+ scripts[_get_name(cls)] = cls
+
+ return scripts
+
+ def save(self, *args, **kwargs):
+ self.file_root = ManagedFileRootPathChoices.SCRIPTS
+ return super().save(*args, **kwargs)
+
+
#
# Reports
#
@@ -832,3 +903,45 @@ class Report(JobResultsMixin, WebhooksMixin, models.Model):
"""
class Meta:
managed = False
+
+
+class ReportModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
+
+ def get_queryset(self):
+ return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
+
+
+class ReportModule(PythonModuleMixin, ManagedFile):
+ """
+ Proxy model for report module files.
+ """
+ objects = ReportModuleManager()
+
+ class Meta:
+ proxy = True
+
+ def get_absolute_url(self):
+ return reverse('extras:report_list')
+
+ @cached_property
+ def reports(self):
+
+ def _get_name(cls):
+ # For child objects in submodules use the full import path w/o the root module as the name
+ return cls.full_name.split(".", maxsplit=1)[1]
+
+ module = self.get_module()
+ reports = {}
+ ordered = getattr(module, 'report_order', [])
+
+ for cls in ordered:
+ reports[_get_name(cls)] = cls
+ for name, cls in inspect.getmembers(module, is_report):
+ if cls not in ordered:
+ reports[_get_name(cls)] = cls
+
+ return reports
+
+ def save(self, *args, **kwargs):
+ self.file_root = ManagedFileRootPathChoices.REPORTS
+ return super().save(*args, **kwargs)
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 0a944a0d26c..cedc1491fdd 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -1,75 +1,23 @@
-import inspect
import logging
-import pkgutil
import traceback
from datetime import timedelta
-from django.conf import settings
from django.utils import timezone
+from django.utils.functional import classproperty
from django_rq import job
from .choices import JobResultStatusChoices, LogLevelChoices
-from .models import JobResult
+from .models import JobResult, ReportModule
logger = logging.getLogger(__name__)
-def is_report(obj):
- """
- Returns True if the given object is a Report.
- """
- return obj in Report.__subclasses__()
-
-
def get_report(module_name, report_name):
"""
Return a specific report from within a module.
"""
- reports = get_reports()
- module = reports.get(module_name)
-
- if module is None:
- return None
-
- report = module.get(report_name)
-
- if report is None:
- return None
-
- return report
-
-
-def get_reports():
- """
- Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
-
- [
- (module_name, (report, report, report, ...)),
- (module_name, (report, report, report, ...)),
- ...
- ]
- """
- module_list = {}
-
- # 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.REPORTS_ROOT]):
- module = importer.find_module(module_name).load_module(module_name)
- report_order = getattr(module, "report_order", ())
- ordered_reports = [cls() for cls in report_order if is_report(cls)]
- unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order]
-
- module_reports = {}
-
- for cls in [*ordered_reports, *unordered_reports]:
- # For reports in submodules use the full import path w/o the root module as the name
- report_name = cls.full_name.split(".", maxsplit=1)[1]
- module_reports[report_name] = cls
-
- if module_reports:
- module_list[module_name] = module_reports
-
- return module_list
+ module = ReportModule.objects.get(file_path=f'{module_name}.py')
+ return module.reports.get(report_name)
@job('default')
@@ -79,7 +27,7 @@ def run_report(job_result, *args, **kwargs):
method for queueing into the background processor.
"""
module_name, report_name = job_result.name.split('.', 1)
- report = get_report(module_name, report_name)
+ report = get_report(module_name, report_name)()
try:
job_result.start()
@@ -136,7 +84,7 @@ def __init__(self):
self.active_test = None
self.failed = False
- self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
+ self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")
# Compile test methods and initialize results skeleton
test_methods = []
@@ -154,13 +102,17 @@ def __init__(self):
raise Exception("A report must contain at least one test method.")
self.test_methods = test_methods
- @property
+ @classproperty
def module(self):
return self.__module__
- @property
+ @classproperty
def class_name(self):
- return self.__class__.__name__
+ return self.__name__
+
+ @classproperty
+ def full_name(self):
+ return f'{self.module}.{self.class_name}'
@property
def name(self):
@@ -169,9 +121,9 @@ def name(self):
"""
return self.class_name
- @property
- def full_name(self):
- return f'{self.module}.{self.class_name}'
+ #
+ # Logging methods
+ #
def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
"""
@@ -228,6 +180,10 @@ def log_failure(self, obj, message):
self.logger.info(f"Failure | {obj}: {message}")
self.failed = True
+ #
+ # Run methods
+ #
+
def run(self, job_result):
"""
Run the report and save its results. Each test method will be executed in order.
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 89dfa32686f..23a9f9f73ac 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -2,9 +2,6 @@
import json
import logging
import os
-import pkgutil
-import sys
-import threading
import traceback
from datetime import timedelta
@@ -17,7 +14,7 @@
from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices
-from extras.models import JobResult
+from extras.models import JobResult, ScriptModule
from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -43,8 +40,6 @@
'TextVar',
]
-lock = threading.Lock()
-
#
# Script variables
@@ -272,7 +267,7 @@ class Meta:
def __init__(self):
# Initiate the log
- self.logger = logging.getLogger(f"netbox.scripts.{self.module()}.{self.__class__.__name__}")
+ self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
self.log = []
# Declare the placeholder for the current request
@@ -286,21 +281,25 @@ def __str__(self):
return self.name
@classproperty
- def name(self):
- return getattr(self.Meta, 'name', self.__name__)
+ def module(self):
+ return self.__module__
+
+ @classproperty
+ def class_name(self):
+ return self.__name__
@classproperty
def full_name(self):
- return '.'.join([self.__module__, self.__name__])
+ return f'{self.module}.{self.class_name}'
+
+ @classproperty
+ def name(self):
+ return getattr(self.Meta, 'name', self.__name__)
@classproperty
def description(self):
return getattr(self.Meta, 'description', '')
- @classmethod
- def module(cls):
- return cls.__module__
-
@classmethod
def root_module(cls):
return cls.__module__.split(".")[0]
@@ -427,15 +426,6 @@ class Script(BaseScript):
# Functions
#
-def is_script(obj):
- """
- Returns True if the object is a Script.
- """
- try:
- return issubclass(obj, Script) and obj != Script
- except TypeError:
- return False
-
def is_variable(obj):
"""
@@ -452,10 +442,10 @@ def run_script(data, request, commit=True, *args, **kwargs):
job_result = kwargs.pop('job_result')
job_result.start()
- module, script_name = job_result.name.split('.', 1)
- script = get_script(module, script_name)()
+ module_name, script_name = job_result.name.split('.', 1)
+ script = get_script(module_name, script_name)()
- logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}")
+ logger = logging.getLogger(f"netbox.scripts.{module_name}.{script_name}")
logger.info(f"Running script (commit={commit})")
# Add files to form data
@@ -522,56 +512,9 @@ def _run_script():
)
-def get_scripts(use_names=False):
- """
- Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
- defined name in place of the actual module name.
- """
- scripts = {}
-
- # Get all modules within the scripts path. These are the user-created files in which scripts are
- # defined.
- modules = list(pkgutil.iter_modules([settings.SCRIPTS_ROOT]))
- modules_bases = set([name.split(".")[0] for _, name, _ in modules])
-
- # Deleting from sys.modules needs to done behind a lock to prevent race conditions where a module is
- # removed from sys.modules while another thread is importing
- with lock:
- for module_name in list(sys.modules.keys()):
- # Everything sharing a base module path with a module in the script folder is removed.
- # We also remove all modules with a base module called "scripts". This allows modifying imported
- # non-script modules without having to reload the RQ worker.
- module_base = module_name.split(".")[0]
- if module_base == "scripts" or module_base in modules_bases:
- del sys.modules[module_name]
-
- for importer, module_name, _ in modules:
- module = importer.find_module(module_name).load_module(module_name)
-
- if use_names and hasattr(module, 'name'):
- module_name = module.name
-
- module_scripts = {}
- script_order = getattr(module, "script_order", ())
- ordered_scripts = [cls for cls in script_order if is_script(cls)]
- unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
-
- for cls in [*ordered_scripts, *unordered_scripts]:
- # For scripts in submodules use the full import path w/o the root module as the name
- script_name = cls.full_name.split(".", maxsplit=1)[1]
- module_scripts[script_name] = cls
-
- if module_scripts:
- scripts[module_name] = module_scripts
-
- return scripts
-
-
def get_script(module_name, script_name):
"""
Retrieve a script class by module and name. Returns None if the script does not exist.
"""
- scripts = get_scripts()
- module = scripts.get(module_name)
- if module:
- return module.get(script_name)
+ module = ScriptModule.objects.get(file_path=f'{module_name}.py')
+ return module.scripts.get(script_name)
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index e9bea09aaf6..f346120689e 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -94,19 +94,23 @@
# Reports
path('reports/', views.ReportListView.as_view(), name='report_list'),
+ path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
path('reports/results//', views.ReportResultView.as_view(), name='report_result'),
- re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', views.ReportView.as_view(), name='report'),
+ path('reports//', include(get_model_urls('extras', 'reportmodule'))),
+ path('reports/./', views.ReportView.as_view(), name='report'),
+
+ # Scripts
+ path('scripts/', views.ScriptListView.as_view(), name='script_list'),
+ path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
+ path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'),
+ path('scripts//', include(get_model_urls('extras', 'scriptmodule'))),
+ path('scripts/./', views.ScriptView.as_view(), name='script'),
# Job results
path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'),
path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'),
path('job-results//delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'),
- # Scripts
- path('scripts/', views.ScriptListView.as_view(), name='script_list'),
- path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'),
- re_path(r'^scripts/(?P.([^.]+)).(?P.(.+))/', views.ScriptView.as_view(), name='script'),
-
# Markdown
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
]
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index f90858bcfd5..23892e09819 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -66,3 +66,25 @@ def register_features(model, features):
raise KeyError(
f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}"
)
+
+
+def is_script(obj):
+ """
+ Returns True if the object is a Script.
+ """
+ from .scripts import Script
+ try:
+ return issubclass(obj, Script) and obj != Script
+ except TypeError:
+ return False
+
+
+def is_report(obj):
+ """
+ Returns True if the given object is a Report.
+ """
+ from .reports import Report
+ try:
+ return issubclass(obj, Report) and obj != Report
+ except TypeError:
+ return False
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 8677c505e23..2ab354bab4c 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -7,6 +7,8 @@
from django.urls import reverse
from django.views.generic import View
+from core.choices import ManagedFileRootPathChoices
+from core.forms import ManagedFileForm
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from netbox.views import generic
@@ -20,8 +22,8 @@
from .choices import JobResultStatusChoices
from .forms.reports import ReportForm
from .models import *
-from .reports import get_report, get_reports, run_report
-from .scripts import get_scripts, run_script
+from .reports import get_report, run_report
+from .scripts import run_script
#
@@ -790,18 +792,34 @@ def post(self, request, id):
# Reports
#
+@register_model_view(ReportModule, 'edit')
+class ReportModuleCreateView(generic.ObjectEditView):
+ queryset = ReportModule.objects.all()
+ form = ManagedFileForm
+
+ def alter_object(self, obj, *args, **kwargs):
+ obj.file_root = ManagedFileRootPathChoices.REPORTS
+ return obj
+
+
+@register_model_view(ReportModule, 'delete')
+class ReportModuleDeleteView(generic.ObjectDeleteView):
+ queryset = ReportModule.objects.all()
+ default_return_url = 'extras:report_list'
+
+
class ReportListView(ContentTypePermissionRequiredMixin, View):
"""
- Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
+ Retrieve all the available reports from disk and the recorded JobResult (if any) for each.
"""
def get_required_permission(self):
return 'extras.view_report'
def get(self, request):
+ report_modules = ReportModule.objects.restrict(request.user)
- reports = get_reports()
report_content_type = ContentType.objects.get(app_label='extras', model='report')
- results = {
+ job_results = {
r.name: r
for r in JobResult.objects.filter(
obj_type=report_content_type,
@@ -809,17 +827,10 @@ def get(self, request):
).order_by('name', '-created').distinct('name').defer('data')
}
- ret = []
-
- for module, report_list in reports.items():
- module_reports = []
- for report in report_list.values():
- report.result = results.get(report.full_name, None)
- module_reports.append(report)
- ret.append((module, module_reports))
-
return render(request, 'extras/report_list.html', {
- 'reports': ret,
+ 'model': ReportModule,
+ 'report_modules': report_modules,
+ 'job_results': job_results,
})
@@ -831,10 +842,8 @@ def get_required_permission(self):
return 'extras.view_report'
def get(self, request, module, name):
-
- report = get_report(module, name)
- if report is None:
- raise Http404
+ module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path=f'{module}.py')
+ report = module.reports[name]()
report_content_type = ContentType.objects.get(app_label='extras', model='report')
report.result = JobResult.objects.filter(
@@ -844,20 +853,17 @@ def get(self, request, module, name):
).first()
return render(request, 'extras/report.html', {
+ 'module': module,
'report': report,
'form': ReportForm(),
})
def post(self, request, module, name):
-
- # Permissions check
if not request.user.has_perm('extras.run_report'):
return HttpResponseForbidden()
- report = get_report(module, name)
- if report is None:
- raise Http404
-
+ module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path=f'{module}.py')
+ report = module.reports[name]()
form = ReportForm(request.POST)
if form.is_valid():
@@ -883,6 +889,7 @@ def post(self, request, module, name):
return redirect('extras:report_result', job_result_pk=job_result.pk)
return render(request, 'extras/report.html', {
+ 'module': module,
'report': report,
'form': form,
})
@@ -924,15 +931,20 @@ def get(self, request, job_result_pk):
# Scripts
#
-class GetScriptMixin:
- def _get_script(self, name, module=None):
- if module is None:
- module, name = name.split('.', 1)
- scripts = get_scripts()
- try:
- return scripts[module][name]()
- except KeyError:
- raise Http404
+@register_model_view(ScriptModule, 'edit')
+class ScriptModuleCreateView(generic.ObjectEditView):
+ queryset = ScriptModule.objects.all()
+ form = ManagedFileForm
+
+ def alter_object(self, obj, *args, **kwargs):
+ obj.file_root = ManagedFileRootPathChoices.SCRIPTS
+ return obj
+
+
+@register_model_view(ScriptModule, 'delete')
+class ScriptModuleDeleteView(generic.ObjectDeleteView):
+ queryset = ScriptModule.objects.all()
+ default_return_url = 'extras:script_list'
class ScriptListView(ContentTypePermissionRequiredMixin, View):
@@ -941,10 +953,10 @@ def get_required_permission(self):
return 'extras.view_script'
def get(self, request):
+ script_modules = ScriptModule.objects.restrict(request.user)
- scripts = get_scripts(use_names=True)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
- results = {
+ job_results = {
r.name: r
for r in JobResult.objects.filter(
obj_type=script_content_type,
@@ -952,22 +964,21 @@ def get(self, request):
).order_by('name', '-created').distinct('name').defer('data')
}
- for _scripts in scripts.values():
- for script in _scripts.values():
- script.result = results.get(script.full_name)
-
return render(request, 'extras/script_list.html', {
- 'scripts': scripts,
+ 'model': ScriptModule,
+ 'script_modules': script_modules,
+ 'job_results': job_results,
})
-class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
+class ScriptView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.view_script'
def get(self, request, module, name):
- script = self._get_script(name, module)
+ module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module}.py')
+ script = module.scripts[name]()
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending JobResult (use the latest one by creation timestamp)
@@ -985,12 +996,11 @@ def get(self, request, module, name):
})
def post(self, request, module, name):
-
- # Permissions check
if not request.user.has_perm('extras.run_script'):
return HttpResponseForbidden()
- script = self._get_script(name, module)
+ module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module}.py')
+ script = module.scripts[name]()
form = script.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running
@@ -1020,7 +1030,7 @@ def post(self, request, module, name):
})
-class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
+class ScriptResultView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.view_script'
@@ -1031,7 +1041,9 @@ def get(self, request, job_result_pk):
if result.obj_type != script_content_type:
raise Http404
- script = self._get_script(result.name)
+ module_name, script_name = result.name.split('.', 1)
+ module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module_name}.py')
+ script = module.scripts[script_name]()
# If this is an HTMX request, return only the result HTML
if is_htmx(request):
diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html
index ddf2c94f4b0..fcf8cae68c4 100644
--- a/netbox/templates/extras/htmx/report_result.html
+++ b/netbox/templates/extras/htmx/report_result.html
@@ -12,7 +12,7 @@
{% if result.completed %}
Duration: {{ result.duration }}
{% endif %}
- {% include 'extras/inc/job_label.html' %}
+ {% badge result.get_status_display result.get_status_color %}
{% if result.completed %}
diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html
index fe06b830986..6037c305230 100644
--- a/netbox/templates/extras/htmx/script_result.html
+++ b/netbox/templates/extras/htmx/script_result.html
@@ -13,7 +13,7 @@
{% if result.completed %}
Duration:
{{ result.duration }}
{% endif %}
-
{% include 'extras/inc/job_label.html' %}
+
{% badge result.get_status_display result.get_status_color %}
{% if result.completed %}
diff --git a/netbox/templates/extras/inc/job_label.html b/netbox/templates/extras/inc/job_label.html
deleted file mode 100644
index 7ff788edef5..00000000000
--- a/netbox/templates/extras/inc/job_label.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% if result.status == 'failed' %}
-
Failed
-{% elif result.status == 'errored' %}
-
Errored
-{% elif result.status == 'pending' %}
-
Pending
-{% elif result.status == 'scheduled' %}
-
Scheduled
-{% elif result.status == 'running' %}
-
Running
-{% elif result.status == 'completed' %}
-
Completed
-{% else %}
-
N/A
-{% endif %}
diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html
index a3876d31927..382c0669f7b 100644
--- a/netbox/templates/extras/report.html
+++ b/netbox/templates/extras/report.html
@@ -10,7 +10,7 @@
{% block breadcrumbs %}
Reports
-
{{ report.module|bettertitle }}
+
{{ report.module|bettertitle }}
{% endblock breadcrumbs %}
{% block subtitle %}
diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html
index 56b92c96dd9..f2c52701319 100644
--- a/netbox/templates/extras/report_list.html
+++ b/netbox/templates/extras/report_list.html
@@ -1,5 +1,7 @@
{% extends 'base/layout.html' %}
+{% load buttons %}
{% load helpers %}
+{% load perms %}
{% block title %}Reports{% endblock %}
@@ -11,50 +13,67 @@
{% endblock tabs %}
+{% block controls %}
+
+
+ {% block extra_controls %}{% endblock %}
+ {% add_button model %}
+
+
+{% endblock controls %}
+
{% block content-wrapper %}
- {% if reports %}
- {% for module, module_reports in reports %}
-
-
-
-
-
-
- Name |
- Status |
- Description |
- Last Run |
- |
-
-
-
- {% for report in module_reports %}
+ {% for module in report_modules %}
+
+
+
+ {% include 'inc/sync_warning.html' with object=module %}
+
+
+
+ Name |
+ Description |
+ Last Run |
+ Status |
+ |
+
+
+
+ {% for report_name, report in module.reports.items %}
+ {% with last_result=job_results|get_key:report.full_name %}
- {{ report.name }}
- |
-
- {% include 'extras/inc/job_label.html' with result=report.result %}
+ {{ report.name }}
|
{{ report.description|markdown|placeholder }} |
-
- {% if report.result %}
- {{ report.result.created|annotated_date }}
- {% else %}
- Never
- {% endif %}
- |
+ {% if last_result %}
+
+ {{ last_result.created|annotated_date }}
+ |
+
+ {% badge last_result.get_status_display last_result.get_status_color %}
+ |
+ {% else %}
+ Never |
+ {{ ''|placeholder }} |
+ {% endif %}
{% if perms.extras.run_report %}
|
- {% for method, stats in report.result.data.items %}
+ {% for method, stats in last_result.data.items %}
{{ method }}
@@ -78,19 +97,19 @@ |
{% endfor %}
- {% endfor %}
-
-
-
+ {% endwith %}
+ {% endfor %}
+
+
- {% endfor %}
- {% else %}
+
+ {% empty %}
No Reports Found
Reports should be saved to {{ settings.REPORTS_ROOT }}
.
This path can be changed by setting REPORTS_ROOT
in NetBox's configuration.
- {% endif %}
+ {% endfor %}
{% endblock content-wrapper %}
diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html
index a7093ec151a..cc12d5a7c19 100644
--- a/netbox/templates/extras/script.html
+++ b/netbox/templates/extras/script.html
@@ -11,7 +11,7 @@
{% block breadcrumbs %}
Scripts
-
{{ module|bettertitle }}
+
{{ module|bettertitle }}
{% endblock breadcrumbs %}
{% block subtitle %}
diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html
index 1f34f4d5eb4..7377d5e8a94 100644
--- a/netbox/templates/extras/script_list.html
+++ b/netbox/templates/extras/script_list.html
@@ -1,8 +1,18 @@
{% extends 'base/layout.html' %}
+{% load buttons %}
{% load helpers %}
{% block title %}Scripts{% endblock %}
+{% block controls %}
+
+
+ {% block extra_controls %}{% endblock %}
+ {% add_button model %}
+
+
+{% endblock controls %}
+
{% block tabs %}
-
@@ -13,56 +23,64 @@
{% block content-wrapper %}
- {% if scripts %}
- {% for module, module_scripts in scripts.items %}
-
-
-
-
-
-
- Name |
- Status |
- Description |
- Last Run |
-
-
-
- {% for class_name, script in module_scripts.items %}
+ {% for module in script_modules %}
+
+
+
+ {% include 'inc/sync_warning.html' with object=module %}
+
+
+
+ Name |
+ Description |
+ Last Run |
+ Status |
+
+
+
+ {% for script_name, script_class in module.scripts.items %}
+ {% with last_result=job_results|get_key:script_class.full_name %}
- {{ script.name }}
- |
-
- {% include 'extras/inc/job_label.html' with result=script.result %}
+ {{ script_class.name }}
|
- {{ script.Meta.description|markdown|placeholder }}
+ {{ script_class.Meta.description|markdown|placeholder }}
|
- {% if script.result %}
+ {% if last_result %}
+
+ {{ last_result.created|annotated_date }}
+ |
- {{ script.result.created|annotated_date }}
+ {% badge last_result.get_status_display last_result.get_status_color %}
|
{% else %}
- Never |
+ Never |
+ {{ ''|placeholder }} |
{% endif %}
- {% endfor %}
-
-
-
+ {% endwith %}
+ {% endfor %}
+
+
- {% endfor %}
- {% else %}
+
+ {% empty %}
No Scripts Found
Scripts should be saved to {{ settings.SCRIPTS_ROOT }}
.
This path can be changed by setting SCRIPTS_ROOT
in NetBox's configuration.
- {% endif %}
+ {% endfor %}
{% endblock content-wrapper %}