Просмотр исходного кода

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
Jeremy Stretch 2 лет назад
Родитель
Сommit
f7a2eb8aef

+ 14 - 1
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')),
+    )

+ 36 - 0
netbox/core/forms/model_forms.py

@@ -3,12 +3,14 @@ import copy
 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 @@ class DataSourceForm(NetBoxModelForm):
         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)

+ 39 - 0
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'),
+        ),
+    ]

+ 1 - 0
netbox/core/models/__init__.py

@@ -1 +1,2 @@
 from .data import *
+from .files import *

+ 13 - 1
netbox/core/models/data.py

@@ -14,7 +14,6 @@ from django.utils import timezone
 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 @@ class DataSource(PrimaryModel):
         """
         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 @@ class DataFile(models.Model):
                 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)

+ 88 - 0
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)

+ 13 - 15
netbox/extras/api/views.py

@@ -16,8 +16,8 @@ from extras import filtersets
 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.exceptions import RQWorkerNotRunningException
 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 @@ class ReportViewSet(ViewSet):
         """
         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 @@ class ReportViewSet(ViewSet):
             ).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 @@ class ScriptViewSet(ViewSet):
             ).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)
 

+ 5 - 9
netbox/extras/management/commands/runreport.py

@@ -5,8 +5,8 @@ from django.core.management.base import BaseCommand
 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 @@ class Command(BaseCommand):
 
     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(

+ 86 - 0
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
+        ),
+    ]

+ 2 - 0
netbox/extras/models/__init__.py

@@ -23,8 +23,10 @@ __all__ = (
     'JournalEntry',
     'ObjectChange',
     'Report',
+    'ReportModule',
     'SavedFilter',
     'Script',
+    'ScriptModule',
     'StagedChange',
     'Tag',
     'TaggedItem',

+ 116 - 3
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 import timezone
 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 @@ __all__ = (
     'JobResult',
     'JournalEntry',
     'Report',
+    'ReportModule',
     'SavedFilter',
     'Script',
+    'ScriptModule',
     'Webhook',
 )
 
@@ -814,6 +822,27 @@ class ConfigRevision(models.Model):
 # 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 Script(JobResultsMixin, WebhooksMixin, models.Model):
         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)

+ 20 - 64
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 @@ class Report(object):
         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 @@ class Report(object):
             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 @@ class Report(object):
         """
         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 @@ class Report(object):
         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.

+ 18 - 75
netbox/extras/scripts.py

@@ -2,9 +2,6 @@ import inspect
 import json
 import logging
 import os
-import pkgutil
-import sys
-import threading
 import traceback
 from datetime import timedelta
 
@@ -17,7 +14,7 @@ from django.utils.functional import classproperty
 
 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 @@ __all__ = [
     'TextVar',
 ]
 
-lock = threading.Lock()
-
 
 #
 # Script variables
@@ -272,7 +267,7 @@ class BaseScript:
     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 @@ class BaseScript:
         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(data, request, commit=True, *args, **kwargs):
         )
 
 
-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)

+ 10 - 6
netbox/extras/urls.py

@@ -94,19 +94,23 @@ urlpatterns = [
 
     # Reports
     path('reports/', views.ReportListView.as_view(), name='report_list'),
+    path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
     path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
-    re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
+    path('reports/<int:pk>/', include(get_model_urls('extras', 'reportmodule'))),
+    path('reports/<path:module>.<str:name>/', 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/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
+    path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
+    path('scripts/<path:module>.<str:name>/', 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/<int:pk>/delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'),
 
-    # Scripts
-    path('scripts/', views.ScriptListView.as_view(), name='script_list'),
-    path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
-    re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
-
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
 ]

+ 22 - 0
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

+ 60 - 48
netbox/extras/views.py

@@ -7,6 +7,8 @@ from django.shortcuts import get_object_or_404, redirect, render
 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 . import filtersets, forms, tables
 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 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
 # 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 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
             ).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 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         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 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         ).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 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
             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 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
 # 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 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         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 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
             ).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 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
         })
 
     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 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
         })
 
 
-class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
+class ScriptResultView(ContentTypePermissionRequiredMixin, View):
 
     def get_required_permission(self):
         return 'extras.view_script'
@@ -1031,7 +1041,9 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
         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):

+ 1 - 1
netbox/templates/extras/htmx/report_result.html

@@ -12,7 +12,7 @@
   {% if result.completed %}
     Duration: <strong>{{ result.duration }}</strong>
   {% endif %}
-  <span id="pending-result-label">{% include 'extras/inc/job_label.html' %}</span>
+  <span id="pending-result-label">{% badge result.get_status_display result.get_status_color %}</span>
 </p>
 {% if result.completed %}
   <div class="card">

+ 1 - 1
netbox/templates/extras/htmx/script_result.html

@@ -13,7 +13,7 @@
   {% if result.completed %}
     Duration: <strong>{{ result.duration }}</strong>
   {% endif %}
-  <span id="pending-result-label">{% include 'extras/inc/job_label.html' %}</span>
+  <span id="pending-result-label">{% badge result.get_status_display result.get_status_color %}</span>
 </p>
 {% if result.completed %}
   <div class="card mb-3">

+ 0 - 15
netbox/templates/extras/inc/job_label.html

@@ -1,15 +0,0 @@
-{% if result.status == 'failed' %}
-    <span class="badge bg-danger">Failed</span>
-{% elif result.status == 'errored' %}
-    <span class="badge bg-danger">Errored</span>
-{% elif result.status == 'pending' %}
-    <span class="badge bg-info">Pending</span>
-{% elif result.status == 'scheduled' %}
-    <span class="badge bg-info">Scheduled</span>
-{% elif result.status == 'running' %}
-    <span class="badge bg-warning">Running</span>
-{% elif result.status == 'completed' %}
-    <span class="badge bg-success">Completed</span>
-{% else %}
-    <span class="badge bg-secondary">N/A</span>
-{% endif %}

+ 1 - 1
netbox/templates/extras/report.html

@@ -10,7 +10,7 @@
 
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
-  <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
+  <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module{{ module.pk }}">{{ report.module|bettertitle }}</a></li>
 {% endblock breadcrumbs %}
 
 {% block subtitle %}

+ 59 - 40
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 @@
   </ul>
 {% endblock tabs %}
 
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% block extra_controls %}{% endblock %}
+      {% add_button model %}
+    </div>
+  </div>
+{% endblock controls %}
+
 {% block content-wrapper %}
   <div class="tab-content">
-    {% if reports %}
-      {% for module, module_reports in reports %}
-        <div class="card">
-          <h5 class="card-header">
-            <a name="module.{{ module }}"></a>
-            <i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
-          </h5>
-          <div class="card-body">
-            <table class="table table-hover table-headings reports">
-              <thead>
-                <tr>
-                  <th width="250">Name</th>
-                  <th width="110">Status</th>
-                  <th>Description</th>
-                  <th width="150" class="text-end">Last Run</th>
-                  <th width="120"></th>
-                </tr>
-              </thead>
-              <tbody>
-                {% for report in module_reports %}
+    {% for module in report_modules %}
+      <div class="card">
+        <h5 class="card-header" id="module{{ module.pk }}">
+          {% if perms.extras.delete_reportmodule %}
+            <div class="float-end">
+              <a href="{% url 'extras:reportmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
+                <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+              </a>
+            </div>
+          {% endif %}
+          <i class="mdi mdi-file-document-outline"></i> {{ module.name|bettertitle }}
+        </h5>
+        <div class="card-body">
+          {% include 'inc/sync_warning.html' with object=module %}
+          <table class="table table-hover table-headings reports">
+            <thead>
+              <tr>
+                <th width="250">Name</th>
+                <th>Description</th>
+                <th>Last Run</th>
+                <th>Status</th>
+                <th width="120"></th>
+              </tr>
+            </thead>
+            <tbody>
+              {% for report_name, report in module.reports.items %}
+                {% with last_result=job_results|get_key:report.full_name %}
                   <tr>
                     <td>
-                      <a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
-                    </td>
-                    <td>
-                      {% include 'extras/inc/job_label.html' with result=report.result %}
+                      <a href="{% url 'extras:report' module=module.path name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
                     </td>
                     <td>{{ report.description|markdown|placeholder }}</td>
-                    <td class="text-end">
-                      {% if report.result %}
-                        <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
-                      {% else %}
-                        <span class="text-muted">Never</span>
-                      {% endif %}
-                    </td>
+                    {% if last_result %}
+                      <td>
+                        <a href="{% url 'extras:report_result' job_result_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                      </td>
+                      <td>
+                        {% badge last_result.get_status_display last_result.get_status_color %}
+                      </td>
+                    {% else %}
+                      <td class="text-muted">Never</td>
+                      <td>{{ ''|placeholder }}</td>
+                    {% endif %}
                     <td>
                       {% if perms.extras.run_report %}
                         <div class="float-end noprint">
                           <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
                             {% csrf_token %}
                             <button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
-                              {% if report.result %}
+                              {% if last_result %}
                                 <i class="mdi mdi-replay"></i> Run Again
                               {% else %}
                                 <i class="mdi mdi-play"></i> Run Report
@@ -65,7 +84,7 @@
                       {% endif %}
                     </td>
                   </tr>
-                  {% for method, stats in report.result.data.items %}
+                  {% for method, stats in last_result.data.items %}
                     <tr>
                       <td colspan="4" class="method">
                         <span class="ps-3">{{ method }}</span>
@@ -78,19 +97,19 @@
                       </td>
                     </tr>
                   {% endfor %}
-                {% endfor %}
-              </tbody>
-            </table>
-          </div>
+                {% endwith %}
+              {% endfor %}
+            </tbody>
+          </table>
         </div>
-      {% endfor %}
-    {% else %}
+      </div>
+    {% empty %}
       <div class="alert alert-info" role="alert">
         <h4 class="alert-heading">No Reports Found</h4>
         Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
         <hr/>
         <small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
       </div>
-    {% endif %}
+    {% endfor %}
   </div>
 {% endblock content-wrapper %}

+ 1 - 1
netbox/templates/extras/script.html

@@ -11,7 +11,7 @@
 
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
-  <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ module }}">{{ module|bettertitle }}</a></li>
+  <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module{{ module.pk }}">{{ module|bettertitle }}</a></li>
 {% endblock breadcrumbs %}
 
 {% block subtitle %}

+ 52 - 34
netbox/templates/extras/script_list.html

@@ -1,8 +1,18 @@
 {% extends 'base/layout.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block title %}Scripts{% endblock %}
 
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% block extra_controls %}{% endblock %}
+      {% add_button model %}
+    </div>
+  </div>
+{% endblock controls %}
+
 {% block tabs %}
   <ul class="nav nav-tabs px-3">
     <li class="nav-item" role="presentation">
@@ -13,56 +23,64 @@
 
 {% block content-wrapper %}
   <div class="tab-content">
-    {% if scripts %}
-      {% for module, module_scripts in scripts.items %}
-        <div class="card">
-          <h5 class="card-header">
-            <a name="module.{{ module }}"></a>
-            <i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
-          </h5>
-          <div class="card-body">
-            <table class="table table-hover table-headings reports">
-              <thead>
-                <tr>
-                  <th width="250">Name</th>
-                  <th width="110">Status</th>
-                  <th>Description</th>
-                  <th class="text-end">Last Run</th>
-                </tr>
-              </thead>
-              <tbody>
-                {% for class_name, script in module_scripts.items %}
+    {% for module in script_modules %}
+      <div class="card">
+        <h5 class="card-header" id="module{{ module.pk }}">
+          {% if perms.extras.delete_scriptmodule %}
+            <div class="float-end">
+              <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
+                <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+              </a>
+            </div>
+          {% endif %}
+          <i class="mdi mdi-file-document-outline"></i> {{ module.name|bettertitle }}
+        </h5>
+        <div class="card-body">
+          {% include 'inc/sync_warning.html' with object=module %}
+          <table class="table table-hover table-headings reports">
+            <thead>
+              <tr>
+                <th width="250">Name</th>
+                <th>Description</th>
+                <th>Last Run</th>
+                <th class="text-end">Status</th>
+              </tr>
+            </thead>
+            <tbody>
+              {% for script_name, script_class in module.scripts.items %}
+                {% with last_result=job_results|get_key:script_class.full_name %}
                   <tr>
                     <td>
-                      <a href="{% url 'extras:script' module=script.root_module name=class_name %}" name="script.{{ class_name }}">{{ script.name }}</a>
-                    </td>
-                    <td>
-                      {% include 'extras/inc/job_label.html' with result=script.result %}
+                      <a href="{% url 'extras:script' module=module.path name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
                     </td>
                     <td>
-                      {{ script.Meta.description|markdown|placeholder }}
+                      {{ script_class.Meta.description|markdown|placeholder }}
                     </td>
-                    {% if script.result %}
+                    {% if last_result %}
+                      <td>
+                        <a href="{% url 'extras:script_result' job_result_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                      </td>
                       <td class="text-end">
-                        <a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
+                        {% badge last_result.get_status_display last_result.get_status_color %}
                       </td>
                     {% else %}
-                      <td class="text-end text-muted">Never</td>
+                      <td class="text-muted">Never</td>
+                      <td class="text-end">{{ ''|placeholder }}</td>
                     {% endif %}
                   </tr>
-                {% endfor %}
-              </tbody>
-            </table>
-          </div>
+                {% endwith %}
+              {% endfor %}
+            </tbody>
+          </table>
         </div>
-      {% endfor %}
-    {% else %}
+      </div>
+    {% empty %}
       <div class="alert alert-info">
         <h4 class="alert-heading">No Scripts Found</h4>
         Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
         <hr/>
         This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
       </div>
-    {% endif %}
+    {% endfor %}
   </div>
 {% endblock content-wrapper %}