Repository: netbox-community/netbox
Pull request: 12510 Merge Scripts and Reports
### Fixes: #12510 

Combined scripts and reports and adds the report summary of logs to scripts.  

**Note:** used unassigned for logs that are in the main or have no method assigned (report logs all are tied to the test_methods).  Not really liking the 'unassigned' name, alternative suggestions welcome.

The old job data record is different for reports and scripts, created a new one for the combined and put in legacy_ templates for the older ones.

![Monosnap Scripts | NetBox 2024-01-30 08-25-38](https://github.com/netbox-community/netbox/assets/99642/d9f903d0-fd0d-4806-9c6c-5c42f7f2ee59)


Problem statement:
Remove support for reports in NetBox v4.0
### Proposed Changes

Remove all support for custom reports. Support for custom scripts will remain in place.

This change is being proposed for NetBox v4.0 to allow users ample time to prepare for the migration.

### Justification

There's a lot of overlap in functionality between reports and scripts, and dropping reports in favor of scripts will allow us to simplify the user experience and remove a substantial amount of largely redundant code.

### Impact

All reports will need to be converted to scripts, which should be fairly straightforward. We can produce supporting documentation to assist users with this process.

Hints from issue discussion:
We primarily use reports for compliance and referential integrity between multiple systems today. From a UX perspective, having the *Run Again* button, the individual test cases with their status badges, and the ability to reference an object directly within the logs are critical to our workflow. I'm all for consolidating these two pieces of functionality, as it would help with a lot of duplicate code on our end too, but it's not clear to me what the straightforward path to convert from a report to a script is at this point. I know *Nautobot* did something like this with their [Jobs](https://docs.nautobot.com/projects/core/en/stable/additional-features/jobs/) functionality by incorporating `run()`, `test_*()`, and `post_run()` methods within the same class. My vote would be to merge the existing *Script* and *Report* functionality in a similar way. It looks like they've added some other nice features, like job buttons within object views and approval functionality as well.
from a permissions POV we have reports and scripts separate right now. once this happens any user who needs to run a report will need the run script permission. this will allow users a higher level of access to make changes

Having a specific permission for scripts that would set whether the user can run scripts which make changes or not would be good.
otherwise there's a risk that a user could run a script thinking it just was a "report" and it making changes.

the current split between reports + scripts effectively handles this but once combined it changes the risk profile
Heya team, regarding this report deprecation and the mention of supporting documentation and/or transitional function for Scripts; do you happen to have a target minor release prior to 4.0 for these considerations?

I'm also mindful of permissions in particular, but appreciate that none of these aspects might be on your radar yet.
@ITJamie raises a good point about a core difference between reports and scripts. Reports are intended (but are not technically required) to be read-only, whereas scripts generally make changes to data. The other core difference IMO is that reports serve to produce well-formatted pass/fail results, whereas scripts are completely open-ended.

Maybe instead of trying to mash these two resources into one, we'd be better served by further differentiating them. One improvement could be to restrict database write operations during the execution of a report, ensuring that a report cannot inadvertently alter data.
If we do extend reports instead of depreciating it, we should extend reports to display returned data like scripts can. 

We have a few "reports" that we have to run as scripts so they can return csv or json data. 

The actual report/script classes under the hood could be merged or have a shared parent class in the longterm to get the featureset and response methods to be the similar but still easily maintainable 
> Maybe instead of trying to mash these two resources into one, we'd be better served by further differentiating them. One improvement could be to restrict database write operations during the execution of a report, ensuring that a report cannot inadvertently alter data.

Could we maybe do a hybrid approach?

* Merge reports in with scripts
* A "script" is a report if it inherits from the "Reports" Superclass
* Reports a restricted from modifying non-report objects

This might also open up the ability to write scripts that can display that nicely formatted pass/fail result that reports can.  It also allows overall efficiencies under the hood.
We decided in the maintainers' meeting today to proceed with this change, merging reports into scripts.

Commit message: 12510 fixes

Patch to review:
diff --git a/netbox/core/migrations/0011_job_report_to_script.py b/netbox/core/migrations/0011_job_report_to_script.py
new file mode 100644
index 00000000000..25bc181c48e
--- /dev/null
+++ b/netbox/core/migrations/0011_job_report_to_script.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.1 on 2024-01-26 18:11
+
+from django.db import migrations
+
+
+def migrate_report_jobs(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Job = apps.get_model('core', 'Job')
+
+    # Delete the new ContentType effected by the introduction of core.ConfigRevision
+    if ContentType.objects.filter(app_label='extras', model='reportmodule'):
+        report_content_type = ContentType.objects.get(app_label='extras', model='reportmodule')
+        script_content_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
+        jobs = Job.objects.filter(object_type_id=report_content_type.id).update(object_type_id=script_content_type.id)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0010_gfk_indexes'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=migrate_report_jobs,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 60a30aed210..7cbea55a08b 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -50,7 +50,6 @@
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
     'ScriptLogMessageSerializer',
-    'ScriptOutputSerializer',
     'ScriptSerializer',
     'TagSerializer',
     'WebhookSerializer',
@@ -593,22 +592,6 @@ def validate_interval(self, value):
         return value
 
 
-class ScriptLogMessageSerializer(serializers.Serializer):
-    status = serializers.SerializerMethodField(read_only=True)
-    message = serializers.SerializerMethodField(read_only=True)
-
-    def get_status(self, instance):
-        return instance[0]
-
-    def get_message(self, instance):
-        return instance[1]
-
-
-class ScriptOutputSerializer(serializers.Serializer):
-    log = ScriptLogMessageSerializer(many=True, read_only=True)
-    output = serializers.CharField(read_only=True)
-
-
 #
 # Change logging
 #
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index 1616b85549e..68b4488bc89 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -20,7 +20,6 @@
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
-router.register('reports', views.ReportViewSet, basename='report')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('content-types', views.ContentTypeViewSet)
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index e0fca86177f..8f5841da15f 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -16,7 +16,6 @@
 from core.models import Job
 from extras import filtersets
 from extras.models import *
-from extras.reports import get_module_and_report, run_report
 from extras.scripts import get_module_and_script, run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
@@ -211,111 +210,6 @@ def render(self, request, pk):
         return self.render_configtemplate(request, configtemplate, context)
 
 
-#
-# Reports
-#
-
-class ReportViewSet(ViewSet):
-    permission_classes = [IsAuthenticatedOrLoginNotRequired]
-    _ignore_model_permissions = True
-    schema = None
-    lookup_value_regex = '[^/]+'  # Allow dots
-
-    def _get_report(self, pk):
-        try:
-            module_name, report_name = pk.split('.', maxsplit=1)
-        except ValueError:
-            raise Http404
-
-        module, report = get_module_and_report(module_name, report_name)
-        if report is None:
-            raise Http404
-
-        return module, report
-
-    def list(self, request):
-        """
-        Compile all reports and their related results (if any). Result data is deferred in the list view.
-        """
-        results = {
-            job.name: job
-            for job in Job.objects.filter(
-                object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
-                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-            ).order_by('name', '-created').distinct('name').defer('data')
-        }
-
-        report_list = []
-        for report_module in ReportModule.objects.restrict(request.user):
-            report_list.extend([report() for report in report_module.reports.values()])
-
-        # Attach Job objects to each report (if any)
-        for report in report_list:
-            report.result = results.get(report.name, None)
-
-        serializer = serializers.ReportSerializer(report_list, many=True, context={
-            'request': request,
-        })
-
-        return Response({'count': len(report_list), 'results': serializer.data})
-
-    def retrieve(self, request, pk):
-        """
-        Retrieve a single Report identified as "<module>.<report>".
-        """
-        module, report = self._get_report(pk)
-
-        # Retrieve the Report and Job, if any.
-        object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
-        report.result = Job.objects.filter(
-            object_type=object_type,
-            name=report.name,
-            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-        ).first()
-
-        serializer = serializers.ReportDetailSerializer(report, context={
-            'request': request
-        })
-
-        return Response(serializer.data)
-
-    @action(detail=True, methods=['post'])
-    def run(self, request, pk):
-        """
-        Run a Report identified as "<module>.<script>" and return the pending Job as the result
-        """
-        # Check that the user has permission to run reports.
-        if not request.user.has_perm('extras.run_report'):
-            raise PermissionDenied("This user does not have permission to run reports.")
-
-        # Check that at least one RQ worker is running
-        if not Worker.count(get_connection('default')):
-            raise RQWorkerNotRunningException()
-
-        # Retrieve and run the Report. This will create a new Job.
-        module, report_cls = self._get_report(pk)
-        report = report_cls
-        input_serializer = serializers.ReportInputSerializer(
-            data=request.data,
-            context={'report': report}
-        )
-
-        if input_serializer.is_valid():
-            report.result = Job.enqueue(
-                run_report,
-                instance=module,
-                name=report.class_name,
-                user=request.user,
-                job_timeout=report.job_timeout,
-                schedule_at=input_serializer.validated_data.get('schedule_at'),
-                interval=input_serializer.validated_data.get('interval')
-            )
-            serializer = serializers.ReportDetailSerializer(report, context={'request': request})
-
-            return Response(serializer.data)
-        return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
-
 #
 # Scripts
 #
diff --git a/netbox/extras/migrations/0106_delete_report_delete_reportmodule.py b/netbox/extras/migrations/0106_delete_report_delete_reportmodule.py
new file mode 100644
index 00000000000..3eb98d56f5f
--- /dev/null
+++ b/netbox/extras/migrations/0106_delete_report_delete_reportmodule.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.0.1 on 2024-01-26 22:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0105_customfield_min_max_values'),
+        ('core', '0011_job_report_to_script'),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name='Report',
+        ),
+        migrations.DeleteModel(
+            name='ReportModule',
+        ),
+    ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index 399f01005c6..ebed3b1fbb7 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -3,7 +3,6 @@
 from .customfields import *
 from .dashboard import *
 from .models import *
-from .reports import *
 from .scripts import *
 from .search import *
 from .staging import *
diff --git a/netbox/extras/models/reports.py b/netbox/extras/models/reports.py
deleted file mode 100644
index f6228ef24a7..00000000000
--- a/netbox/extras/models/reports.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import inspect
-import logging
-from functools import cached_property
-
-from django.db import models
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
-
-from core.choices import ManagedFileRootPathChoices
-from core.models import ManagedFile
-from extras.utils import is_report
-from netbox.models.features import JobsMixin, EventRulesMixin
-from utilities.querysets import RestrictedQuerySet
-from .mixins import PythonModuleMixin
-
-logger = logging.getLogger('netbox.reports')
-
-__all__ = (
-    'Report',
-    'ReportModule',
-)
-
-
-class Report(EventRulesMixin, models.Model):
-    """
-    Dummy model used to generate permissions for reports. Does not exist in the database.
-    """
-    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, JobsMixin, ManagedFile):
-    """
-    Proxy model for report module files.
-    """
-    objects = ReportModuleManager()
-
-    class Meta:
-        proxy = True
-        verbose_name = _('report module')
-        verbose_name_plural = _('report modules')
-
-    def get_absolute_url(self):
-        return reverse('extras:report_list')
-
-    def __str__(self):
-        return self.python_name
-
-    @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]
-
-        try:
-            module = self.get_module()
-        except (ImportError, SyntaxError) as e:
-            logger.error(f"Unable to load report module {self.name}, exception: {e}")
-            return {}
-        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/models/scripts.py b/netbox/extras/models/scripts.py
index 93275acdab0..55576421f5d 100644
--- a/netbox/extras/models/scripts.py
+++ b/netbox/extras/models/scripts.py
@@ -3,12 +3,13 @@
 from functools import cached_property
 
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
 from core.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
-from extras.utils import is_script
+from extras.utils import is_script_or_report
 from netbox.models.features import JobsMixin, EventRulesMixin
 from utilities.querysets import RestrictedQuerySet
 from .mixins import PythonModuleMixin
@@ -32,7 +33,8 @@ class Meta:
 class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
     def get_queryset(self):
-        return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
+        return super().get_queryset().filter(
+            Q(file_root=ManagedFileRootPathChoices.SCRIPTS) | Q(file_root=ManagedFileRootPathChoices.REPORTS))
 
 
 class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
@@ -70,7 +72,7 @@ def _get_name(cls):
 
         for cls in ordered:
             scripts[_get_name(cls)] = cls
-        for name, cls in inspect.getmembers(module, is_script):
+        for name, cls in inspect.getmembers(module, is_script_or_report):
             if cls not in ordered:
                 scripts[_get_name(cls)] = cls
 
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 90641cc84a1..54f80b0c4ef 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -1,248 +1,19 @@
-import inspect
-import logging
-import traceback
-from datetime import timedelta
-
-from django.utils import timezone
-from django.utils.functional import classproperty
-from django_rq import job
-
-from core.choices import JobStatusChoices
-from core.models import Job
-from .choices import LogLevelChoices
-from .models import ReportModule
+from .scripts import BaseScript
 
 __all__ = (
     'Report',
-    'get_module_and_report',
-    'run_report',
 )
 
-logger = logging.getLogger(__name__)
-
-
-def get_module_and_report(module_name, report_name):
-    module = ReportModule.objects.get(file_path=f'{module_name}.py')
-    report = module.reports.get(report_name)()
-    return module, report
-
-
-@job('default')
-def run_report(job, *args, **kwargs):
-    """
-    Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
-    method for queueing into the background processor.
-    """
-    job.start()
-
-    module = ReportModule.objects.get(pk=job.object_id)
-    report = module.reports.get(job.name)()
-
-    try:
-        report.run(job)
-    except Exception as e:
-        job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
-        logging.error(f"Error during execution of report {job.name}")
-    finally:
-        # Schedule the next job if an interval has been set
-        if job.interval:
-            new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
-            Job.enqueue(
-                run_report,
-                instance=job.object,
-                name=job.name,
-                user=job.user,
-                job_timeout=report.job_timeout,
-                schedule_at=new_scheduled_time,
-                interval=job.interval
-            )
-
-
-class Report(object):
-    """
-    NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
-    report must have one or more test methods named `test_*`.
-
-    The `_results` attribute of a completed report will take the following form:
-
-    {
-        'test_bar': {
-            'failures': 42,
-            'log': [
-                (<datetime>, <level>, <object>, <message>),
-                ...
-            ]
-        },
-        'test_foo': {
-            'failures': 0,
-            'log': [
-                (<datetime>, <level>, <object>, <message>),
-                ...
-            ]
-        }
-    }
-    """
-    description = None
-    scheduling_enabled = True
-    job_timeout = None
-
-    def __init__(self):
-
-        self._results = {}
-        self.active_test = None
-        self.failed = False
-
-        self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")
-
-        # Compile test methods and initialize results skeleton
-        test_methods = []
-        for method in dir(self):
-            if method.startswith('test_') and callable(getattr(self, method)):
-                test_methods.append(method)
-                self._results[method] = {
-                    'success': 0,
-                    'info': 0,
-                    'warning': 0,
-                    'failure': 0,
-                    'log': [],
-                }
-        self.test_methods = test_methods
-
-    @classproperty
-    def module(self):
-        return self.__module__
-
-    @classproperty
-    def class_name(self):
-        return self.__name__
-
-    @classproperty
-    def full_name(self):
-        return f'{self.module}.{self.class_name}'
-
-    @property
-    def name(self):
-        """
-        Override this attribute to set a custom display name.
-        """
-        return self.class_name
-
-    @property
-    def filename(self):
-        return inspect.getfile(self.__class__)
-
-    @property
-    def source(self):
-        return inspect.getsource(self.__class__)
-
-    @property
-    def is_valid(self):
-        """
-        Indicates whether the report can be run.
-        """
-        return bool(self.test_methods)
-
-    #
-    # Logging methods
-    #
-
-    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
-        """
-        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
-        """
-        if level not in LogLevelChoices.values():
-            raise Exception(f"Unknown logging level: {level}")
-        self._results[self.active_test]['log'].append((
-            timezone.now().isoformat(),
-            level,
-            str(obj) if obj else None,
-            obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
-            message,
-        ))
-
-    def log(self, message):
-        """
-        Log a message which is not associated with a particular object.
-        """
-        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
-        self.logger.info(message)
-
-    def log_success(self, obj, message=None):
-        """
-        Record a successful test against an object. Logging a message is optional.
-        """
-        if message:
-            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
-        self._results[self.active_test]['success'] += 1
-        self.logger.info(f"Success | {obj}: {message}")
-
-    def log_info(self, obj, message):
-        """
-        Log an informational message.
-        """
-        self._log(obj, message, level=LogLevelChoices.LOG_INFO)
-        self._results[self.active_test]['info'] += 1
-        self.logger.info(f"Info | {obj}: {message}")
-
-    def log_warning(self, obj, message):
-        """
-        Log a warning.
-        """
-        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
-        self._results[self.active_test]['warning'] += 1
-        self.logger.info(f"Warning | {obj}: {message}")
-
-    def log_failure(self, obj, message):
-        """
-        Log a failure. Calling this method will automatically mark the report as failed.
-        """
-        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
-        self._results[self.active_test]['failure'] += 1
-        self.logger.info(f"Failure | {obj}: {message}")
-        self.failed = True
-
-    #
-    # Run methods
-    #
-
-    def run(self, job):
-        """
-        Run the report and save its results. Each test method will be executed in order.
-        """
-        self.logger.info(f"Running report")
-
-        # Perform any post-run tasks
-        self.pre_run()
 
-        try:
-            for method_name in self.test_methods:
-                self.active_test = method_name
-                test_method = getattr(self, method_name)
-                test_method()
-            job.data = self._results
-            if self.failed:
-                self.logger.warning("Report failed")
-                job.terminate(status=JobStatusChoices.STATUS_FAILED)
-            else:
-                self.logger.info("Report completed successfully")
-                job.terminate()
-        except Exception as e:
-            stacktrace = traceback.format_exc()
-            self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
-            logger.error(f"Exception raised during report execution: {e}")
-            job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
+class Report(BaseScript):
+    def log_success(self, obj=None, message=None):
+        super().log_success(message, obj)
 
-        # Perform any post-run tasks
-        self.post_run()
+    def log_info(self, obj=None, message=None):
+        super().log_info(message, obj)
 
-    def pre_run(self):
-        """
-        Extend this method to include any tasks which should execute *before* the report is run.
-        """
-        pass
+    def log_warning(self, obj=None, message=None):
+        super().log_warning(message, obj)
 
-    def post_run(self):
-        """
-        Extend this method to include any tasks which should execute *after* the report is run.
-        """
-        pass
+    def log_failure(self, obj=None, message=None):
+        super().log_failure(message, obj)
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index f28465547be..8792e08fa0c 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -10,11 +10,11 @@
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.db import transaction
+from django.utils import timezone
 from django.utils.functional import classproperty
 
 from core.choices import JobStatusChoices
 from core.models import Job
-from extras.api.serializers import ScriptOutputSerializer
 from extras.choices import LogLevelChoices
 from extras.models import ScriptModule
 from extras.signals import clear_events
@@ -257,7 +257,7 @@ def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwar
 # Scripts
 #
 
-class BaseScript:
+class BaseScript(object):
     """
     Base model for custom scripts. User classes should inherit from this model if they want to extend Script
     functionality for use in other subclasses.
@@ -270,17 +270,37 @@ class Meta:
         pass
 
     def __init__(self):
+        self._logs = {}
+        self._failed = False
+        self._current_method = 'unassigned'
+        self._output = ''
 
         # Initiate the log
         self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
-        self.log = []
 
         # Declare the placeholder for the current request
         self.request = None
 
-        # Grab some info about the script
-        self.filename = inspect.getfile(self.__class__)
-        self.source = inspect.getsource(self.__class__)
+        # Compile test methods and initialize results skeleton
+        self._logs['unassigned'] = {
+            'success': 0,
+            'info': 0,
+            'warning': 0,
+            'failure': 0,
+            'log': [],
+        }
+        test_methods = []
+        for method in dir(self):
+            if method.startswith('test_') and callable(getattr(self, method)):
+                test_methods.append(method)
+                self._logs[method] = {
+                    'success': 0,
+                    'info': 0,
+                    'warning': 0,
+                    'failure': 0,
+                    'log': [],
+                }
+        self.test_methods = test_methods
 
     def __str__(self):
         return self.name
@@ -331,6 +351,21 @@ def job_timeout(self):
     def scheduling_enabled(self):
         return getattr(self.Meta, 'scheduling_enabled', True)
 
+    @property
+    def filename(self):
+        return inspect.getfile(self.__class__)
+
+    @property
+    def source(self):
+        return inspect.getsource(self.__class__)
+
+    @property
+    def is_valid(self):
+        """
+        Indicates whether the report can be run.
+        """
+        return bool(self.test_methods)
+
     @classmethod
     def _get_vars(cls):
         vars = {}
@@ -399,25 +434,54 @@ def as_form(self, data=None, files=None, initial=None):
 
     # Logging
 
-    def log_debug(self, message):
-        self.logger.log(logging.DEBUG, message)
-        self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
+    def _log(self, message, obj=None, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.INFO):
+        """
+        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
+        """
+        if log_level not in LogLevelChoices.values():
+            raise Exception(f"Unknown logging level: {log_level}")
+
+        if message:
+            self._logs[self._current_method]['log'].append((
+                timezone.now().isoformat(),
+                log_level,
+                str(obj) if obj else None,
+                obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
+                message,
+            ))
+
+        if log_level != LogLevelChoices.LOG_DEFAULT:
+            self._logs[self._current_method][log_level] += 1
+
+            if self._current_method != 'unassigned':
+                self._logs['unassigned'][log_level] += 1
+
+        if obj:
+            self.logger.log(level, f"{log_level.capitalize()} | {obj}: {message}")
+        else:
+            self.logger.log(level, message)  # No syslog equivalent for SUCCESS
+
+    def log(self, message):
+        """
+        Log a message which is not associated with a particular object.
+        """
+        self._log(str(message), None, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.INFO)
 
-    def log_success(self, message):
-        self.logger.log(logging.INFO, message)  # No syslog equivalent for SUCCESS
-        self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
+    def log_debug(self, message, obj=None):
+        self._log(str(message), obj, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.DEBUG)
 
-    def log_info(self, message):
-        self.logger.log(logging.INFO, message)
-        self.log.append((LogLevelChoices.LOG_INFO, str(message)))
+    def log_success(self, message, obj=None):
+        self._log(str(message), obj, log_level=LogLevelChoices.LOG_SUCCESS, level=logging.INFO)
 
-    def log_warning(self, message):
-        self.logger.log(logging.WARNING, message)
-        self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
+    def log_info(self, message, obj=None):
+        self._log(str(message), obj, log_level=LogLevelChoices.LOG_INFO, level=logging.INFO)
 
-    def log_failure(self, message):
-        self.logger.log(logging.ERROR, message)
-        self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
+    def log_warning(self, message, obj=None):
+        self._log(str(message), obj, log_level=LogLevelChoices.LOG_WARNING, level=logging.WARNING)
+
+    def log_failure(self, message, obj=None):
+        self._log(str(message), obj, log_level=LogLevelChoices.LOG_FAILURE, level=logging.ERROR)
+        self._failed = True
 
     # Convenience functions
 
@@ -446,6 +510,44 @@ def load_json(self, filename):
 
         return data
 
+    def run_test_scripts(self, job):
+        """
+        Run the report and save its results. Each test method will be executed in order.
+        """
+        self.logger.info(f"Running report")
+
+        # Perform any post-run tasks
+        self.pre_run()
+
+        try:
+            for method_name in self.test_methods:
+                self._current_method = method_name
+                test_method = getattr(self, method_name)
+                test_method()
+        except Exception as e:
+            self.post_run()
+            self._current_method = 'unassigned'
+            raise e
+
+        # Perform any post-run tasks
+        self.post_run()
+        self._current_method = 'unassigned'
+
+    def run(self, data, commit):
+        self.run_test_scripts()
+
+    def pre_run(self):
+        """
+        Extend this method to include any tasks which should execute *before* the report is run.
+        """
+        pass
+
+    def post_run(self):
+        """
+        Extend this method to include any tasks which should execute *after* the report is run.
+        """
+        pass
+
 
 class Script(BaseScript):
     """
@@ -500,6 +602,61 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     # Add the current request as a property of the script
     script.request = request
 
+    def signature(fn):
+        """
+        Taken from django.tables2 (https://github.com/jieter/django-tables2/blob/master/django_tables2/utils.py)
+        Returns:
+            tuple: Returns a (arguments, kwarg_name)-tuple:
+                 - the arguments (positional or keyword)
+                 - the name of the ** kwarg catch all.
+
+        The self-argument for methods is always removed.
+        """
+
+        signature = inspect.signature(fn)
+
+        args = []
+        keywords = None
+        for arg in signature.parameters.values():
+            if arg.kind == arg.VAR_KEYWORD:
+                keywords = arg.name
+            elif arg.kind == arg.VAR_POSITIONAL:
+                continue  # skip *args catch-all
+            else:
+                args.append(arg.name)
+
+        return tuple(args), keywords
+
+    def call_with_appropriate(fn, kwargs):
+        """
+        Taken from django.tables2 (https://github.com/jieter/django-tables2/blob/master/django_tables2/utils.py)
+        Calls the function ``fn`` with the keyword arguments from ``kwargs`` it expects
+
+        If the kwargs argument is defined, pass all arguments, else provide exactly
+        the arguments wanted.
+
+        If one of the arguments of ``fn`` are not contained in kwargs, ``fn`` will not
+        be called and ``None`` will be returned.
+        """
+        args, kwargs_name = signature(fn)
+        # no catch-all defined, we need to exactly pass the arguments specified.
+        if not kwargs_name:
+            kwargs = {key: kwargs[key] for key in kwargs if key in args}
+
+            # if any argument of fn is not in kwargs, just return None
+            if any(arg not in kwargs for arg in args):
+                return None
+
+        return fn(**kwargs)
+
+    def set_job_data(script):
+        logs = script._logs
+        job.data = {
+            'logs': logs,
+            'output': script._output,
+        }
+        return job
+
     def _run_script():
         """
         Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
@@ -508,25 +665,29 @@ def _run_script():
         try:
             try:
                 with transaction.atomic():
-                    script.output = script.run(data=data, commit=commit)
+                    script._output = script.run(data, commit)
                     if not commit:
                         raise AbortTransaction()
             except AbortTransaction:
-                script.log_info("Database changes have been reverted automatically.")
+                call_with_appropriate(script.log_info, kwargs={'message': "Database changes have been reverted automatically."})
                 if request:
                     clear_events.send(request)
-            job.data = ScriptOutputSerializer(script).data
-            job.terminate()
+            job = set_job_data(script)
+            if script._failed:
+                logger.warning(f"Script failed")
+                job.terminate(status=JobStatusChoices.STATUS_FAILED)
+            else:
+                job.terminate()
         except Exception as e:
             if type(e) is AbortScript:
-                script.log_failure(f"Script aborted with error: {e}")
+                call_with_appropriate(script.log_failure, kwargs={'message': f"Script aborted with error: {e}"})
                 logger.error(f"Script aborted with error: {e}")
             else:
                 stacktrace = traceback.format_exc()
-                script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
+                call_with_appropriate(script.log_failure, kwargs={'message': f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"})
                 logger.error(f"Exception raised during script execution: {e}")
-            script.log_info("Database changes have been reverted due to error.")
-            job.data = ScriptOutputSerializer(script).data
+            call_with_appropriate(script.log_info, kwargs={'message': "Database changes have been reverted due to error."})
+            job = set_job_data(job, script)
             job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
             if request:
                 clear_events.send(request)
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 93be2d2c4ba..0c414ef217e 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -730,37 +730,6 @@ def setUpTestData(cls):
         ConfigTemplate.objects.bulk_create(config_templates)
 
 
-class ReportTest(APITestCase):
-
-    class TestReport(Report):
-
-        def test_foo(self):
-            self.log_success(None, "Report completed")
-
-    @classmethod
-    def setUpTestData(cls):
-        ReportModule.objects.create(
-            file_root=ManagedFileRootPathChoices.REPORTS,
-            file_path='/var/tmp/report.py'
-        )
-
-    def get_test_report(self, *args):
-        return ReportModule.objects.first(), self.TestReport()
-
-    def setUp(self):
-        super().setUp()
-
-        # Monkey-patch the API viewset's _get_report() method to return our test Report above
-        from extras.api.views import ReportViewSet
-        ReportViewSet._get_report = self.get_test_report
-
-    def test_get_report(self):
-        url = reverse('extras-api:report-detail', kwargs={'pk': None})
-        response = self.client.get(url, **self.header)
-
-        self.assertEqual(response.data['name'], self.TestReport.__name__)
-
-
 class ScriptTest(APITestCase):
 
     class TestScript(Script):
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index 0a1786f1f39..5627fad5d40 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -116,15 +116,6 @@
     path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
     path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
 
-    # 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_pk>/', views.ReportResultView.as_view(), name='report_result'),
-    path('reports/<int:pk>/', include(get_model_urls('extras', 'reportmodule'))),
-    path('reports/<str:module>/<str:name>/', views.ReportView.as_view(), name='report'),
-    path('reports/<str:module>/<str:name>/source/', views.ReportSourceView.as_view(), name='report_source'),
-    path('reports/<str:module>/<str:name>/jobs/', views.ReportJobsView.as_view(), name='report_jobs'),
-
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index c6b2de18838..563770f73b3 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -67,3 +67,15 @@ def is_report(obj):
         return issubclass(obj, Report) and obj != Report
     except TypeError:
         return False
+
+
+def is_script_or_report(obj):
+    """
+    Returns True if the given object is a Report.
+    """
+    from .reports import Report
+    from .scripts import Script
+    try:
+        return ((issubclass(obj, Report) and obj != Report) or (issubclass(obj, Script) and obj != Script))
+    except TypeError:
+        return False
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index f33b4c89b71..4c9d25cebf4 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -26,7 +26,6 @@
 from . import filtersets, forms, tables
 from .forms.reports import ReportForm
 from .models import *
-from .reports import run_report
 from .scripts import run_script
 
 
@@ -1006,185 +1005,6 @@ def post(self, request, id):
         return redirect(reverse('home'))
 
 
-#
-# 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 the available reports from disk and the recorded Job (if any) for each.
-    """
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request):
-        report_modules = ReportModule.objects.restrict(request.user)
-
-        return render(request, 'extras/report_list.html', {
-            'model': ReportModule,
-            'report_modules': report_modules,
-        })
-
-
-def get_report_module(module, request):
-    return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
-
-
-class ReportView(ContentTypePermissionRequiredMixin, View):
-    """
-    Display a single Report and its associated Job (if any).
-    """
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, module, name):
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-
-        object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
-        report.result = Job.objects.filter(
-            object_type=object_type,
-            object_id=module.pk,
-            name=report.name,
-            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-        ).first()
-
-        return render(request, 'extras/report.html', {
-            'module': module,
-            'report': report,
-            'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
-        })
-
-    def post(self, request, module, name):
-        if not request.user.has_perm('extras.run_report'):
-            return HttpResponseForbidden()
-
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-        form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
-
-        if form.is_valid():
-
-            # Allow execution only if RQ worker process is running
-            if not get_workers_for_queue('default'):
-                messages.error(request, "Unable to run report: RQ worker process not running.")
-                return render(request, 'extras/report.html', {
-                    'report': report,
-                })
-
-            # Run the Report. A new Job is created.
-            job = Job.enqueue(
-                run_report,
-                instance=module,
-                name=report.class_name,
-                user=request.user,
-                schedule_at=form.cleaned_data.get('schedule_at'),
-                interval=form.cleaned_data.get('interval'),
-                job_timeout=report.job_timeout
-            )
-
-            return redirect('extras:report_result', job_pk=job.pk)
-
-        return render(request, 'extras/report.html', {
-            'module': module,
-            'report': report,
-            'form': form,
-        })
-
-
-class ReportSourceView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, module, name):
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-
-        return render(request, 'extras/report/source.html', {
-            'module': module,
-            'report': report,
-            'tab': 'source',
-        })
-
-
-class ReportJobsView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, module, name):
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-
-        object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
-        jobs = Job.objects.filter(
-            object_type=object_type,
-            object_id=module.pk,
-            name=report.class_name
-        )
-
-        jobs_table = JobTable(
-            data=jobs,
-            orderable=False,
-            user=request.user
-        )
-        jobs_table.configure(request)
-
-        return render(request, 'extras/report/jobs.html', {
-            'module': module,
-            'report': report,
-            'table': jobs_table,
-            'tab': 'jobs',
-        })
-
-
-class ReportResultView(ContentTypePermissionRequiredMixin, View):
-    """
-    Display a Job pertaining to the execution of a Report.
-    """
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, job_pk):
-        object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='reportmodule')
-        job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
-
-        module = job.object
-        report = module.reports[job.name]
-
-        # If this is an HTMX request, return only the result HTML
-        if request.htmx:
-            response = render(request, 'extras/htmx/report_result.html', {
-                'report': report,
-                'job': job,
-            })
-            if job.completed or not job.started:
-                response.status_code = 286
-            return response
-
-        return render(request, 'extras/report_result.html', {
-            'report': report,
-            'job': job,
-        })
-
-
 #
 # Scripts
 #
@@ -1343,11 +1163,21 @@ def get(self, request, job_pk):
         module = job.object
         script = module.scripts[job.name]()
 
+        legacy_script = False
+        legacy_report = False
+        if job.data and ('logs' not in job.data):
+            if 'log' in job.data:
+                legacy_script = True
+            else:
+                legacy_report = True
+
         # If this is an HTMX request, return only the result HTML
         if request.htmx:
             response = render(request, 'extras/htmx/script_result.html', {
                 'script': script,
                 'job': job,
+                'legacy_script': legacy_script,
+                'legacy_report': legacy_report,
             })
             if job.completed or not job.started:
                 response.status_code = 286
@@ -1356,6 +1186,8 @@ def get(self, request, job_pk):
         return render(request, 'extras/script_result.html', {
             'script': script,
             'job': job,
+            'legacy_script': legacy_script,
+            'legacy_report': legacy_report,
         })
 
 
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index e639474244b..e3e3f05817f 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -317,14 +317,8 @@
             ),
         ),
         MenuGroup(
-            label=_('Reports & Scripts'),
+            label=_('Scripts'),
             items=(
-                MenuItem(
-                    link='extras:report_list',
-                    link_text=_('Reports'),
-                    permissions=['extras.view_report'],
-                    buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
-                ),
                 MenuItem(
                     link='extras:script_list',
                     link_text=_('Scripts'),
diff --git a/netbox/templates/extras/htmx/legacy_report_result.html b/netbox/templates/extras/htmx/legacy_report_result.html
new file mode 100644
index 00000000000..e6b6caf7317
--- /dev/null
+++ b/netbox/templates/extras/htmx/legacy_report_result.html
@@ -0,0 +1,81 @@
+{% load humanize %}
+{% load helpers %}
+{% load i18n %}
+
+<p>
+  {% if job.started %}
+    {% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
+  {% elif job.scheduled %}
+    {% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
+  {% else %}
+    {% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
+  {% endif %}
+  {% if job.completed %}
+    {% trans "Duration" %}: <strong>{{ job.duration }}</strong>
+  {% endif %}
+  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
+</p>
+{% if job.completed %}
+  <div class="card">
+    <h5 class="card-header">{% trans "Report Methods" %}</h5>
+    <div class="card-body">
+      <table class="table table-hover">
+        {% for method, data in job.data.items %}
+          <tr>
+            <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
+            <td class="text-end report-stats">
+              <span class="badge bg-success">{{ data.success }}</span>
+              <span class="badge bg-info">{{ data.info }}</span>
+              <span class="badge bg-warning">{{ data.warning }}</span>
+              <span class="badge bg-danger">{{ data.failure }}</span>
+            </td>
+          </tr>
+        {% endfor %}
+      </table>
+    </div>
+  </div>
+  <div class="card">
+    <h5 class="card-header">{% trans "Report Results" %}</h5>
+    <div class="card-body">
+      <table class="table table-hover report">
+        <thead>
+          <tr class="table-headings">
+            <th>{% trans "Time" %}</th>
+            <th>{% trans "Level" %}</th>
+            <th>{% trans "Object" %}</th>
+            <th>{% trans "Message" %}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for method, data in job.data.items %}
+            <tr>
+              <th colspan="4" style="font-family: monospace">
+                <a name="{{ method }}"></a>{{ method }}
+              </th>
+            </tr>
+            {% for time, level, obj, url, message in data.log %}
+              <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
+                <td>{{ time }}</td>
+                <td>
+                  <label class="badge bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
+                </td>
+                <td>
+                  {% if obj and url %}
+                    <a href="{{ url }}">{{ obj }}</a>
+                  {% elif obj %}
+                    {{ obj }}
+                  {% else %}
+                    {{ ''|placeholder }}
+                  {% endif %}
+                </td>
+                <td class="rendered-markdown">{{ message|markdown }}</td>
+              </tr>
+            {% endfor %}
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  </div>
+{% elif job.started %}
+  {% include 'extras/inc/result_pending.html' %}
+{% endif %}
diff --git a/netbox/templates/extras/htmx/legacy_script_result.html b/netbox/templates/extras/htmx/legacy_script_result.html
new file mode 100644
index 00000000000..ddc9b05f609
--- /dev/null
+++ b/netbox/templates/extras/htmx/legacy_script_result.html
@@ -0,0 +1,58 @@
+{% load humanize %}
+{% load helpers %}
+{% load log_levels %}
+{% load i18n %}
+
+<p>
+  {% if job.started %}
+    {% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
+  {% elif job.scheduled %}
+    {% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
+  {% else %}
+    {% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
+  {% endif %}
+  {% if job.completed %}
+    {% trans "Duration" %}: <strong>{{ job.duration }}</strong>
+  {% endif %}
+  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
+</p>
+{% if job.completed %}
+  <div class="card mb-3">
+    <h5 class="card-header">{% trans "Script Log" %}</h5>
+    <div class="card-body">
+      <table class="table table-hover panel-body">
+        <tr>
+          <th>{% trans "Line" %}</th>
+          <th>{% trans "Level" %}</th>
+          <th>{% trans "Message" %}</th>
+        </tr>
+        {% for log in job.data.log %}
+          <tr>
+            <td>{{ forloop.counter }}</td>
+            <td>{% log_level log.status %}</td>
+            <td class="rendered-markdown">{{ log.message|markdown }}</td>
+          </tr>
+        {% empty %}
+          <tr>
+            <td colspan="3" class="text-center text-muted">
+              {% trans "No log output" %}
+            </td>
+          </tr>
+        {% endfor %}
+      </table>
+    </div>
+    {% if execution_time %}
+      <div class="card-footer text-end text-muted">
+        <small>{% trans "Exec Time" %}: {{ execution_time|floatformat:3 }} {% trans "seconds" context "Unit of time" %}</small>
+      </div>
+    {% endif %}
+  </div>
+  <h4>{% trans "Output" %}</h4>
+  {% if job.data.output %}
+    <pre class="block">{{ job.data.output }}</pre>
+  {% else %}
+    <p class="text-muted">{% trans "None" %}</p>
+  {% endif %}
+{% elif job.started %}
+  {% include 'extras/inc/result_pending.html' %}
+{% endif %}
diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html
deleted file mode 100644
index 440b12ebae2..00000000000
--- a/netbox/templates/extras/htmx/report_result.html
+++ /dev/null
@@ -1,77 +0,0 @@
-{% load humanize %}
-{% load helpers %}
-{% load i18n %}
-
-<p>
-  {% if job.started %}
-    {% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
-  {% elif job.scheduled %}
-    {% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
-  {% else %}
-    {% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
-  {% endif %}
-  {% if job.completed %}
-    {% trans "Duration" %}: <strong>{{ job.duration }}</strong>
-  {% endif %}
-  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
-</p>
-{% if job.completed %}
-  <div class="card">
-    <h5 class="card-header">{% trans "Report Methods" %}</h5>
-    <table class="table table-hover">
-      {% for method, data in job.data.items %}
-        <tr>
-          <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
-          <td class="text-end report-stats">
-            <span class="badge text-bg-success">{{ data.success }}</span>
-            <span class="badge text-bg-info">{{ data.info }}</span>
-            <span class="badge text-bg-warning">{{ data.warning }}</span>
-            <span class="badge text-bg-danger">{{ data.failure }}</span>
-          </td>
-        </tr>
-      {% endfor %}
-    </table>
-  </div>
-  <div class="card">
-    <h5 class="card-header">{% trans "Report Results" %}</h5>
-    <table class="table table-hover report">
-      <thead>
-        <tr>
-          <th>{% trans "Time" %}</th>
-          <th>{% trans "Level" %}</th>
-          <th>{% trans "Object" %}</th>
-          <th>{% trans "Message" %}</th>
-        </tr>
-      </thead>
-      <tbody>
-        {% for method, data in job.data.items %}
-          <tr>
-            <th colspan="4" style="font-family: monospace">
-              <a name="{{ method }}"></a>{{ method }}
-            </th>
-          </tr>
-          {% for time, level, obj, url, message in data.log %}
-            <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
-              <td>{{ time }}</td>
-              <td>
-                <label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
-              </td>
-              <td>
-                {% if obj and url %}
-                  <a href="{{ url }}">{{ obj }}</a>
-                {% elif obj %}
-                  {{ obj }}
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-              <td class="rendered-markdown">{{ message|markdown }}</td>
-            </tr>
-          {% endfor %}
-        {% endfor %}
-      </tbody>
-    </table>
-  </div>
-{% elif job.started %}
-  {% include 'extras/inc/result_pending.html' %}
-{% endif %}
diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html
index 2fb0de46809..f27672d1039 100644
--- a/netbox/templates/extras/htmx/script_result.html
+++ b/netbox/templates/extras/htmx/script_result.html
@@ -1,6 +1,5 @@
 {% load humanize %}
 {% load helpers %}
-{% load log_levels %}
 {% load i18n %}
 
 <p>
@@ -17,40 +16,59 @@
   <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
 </p>
 {% if job.completed %}
-  <div class="card mb-3">
-    <h5 class="card-header">{% trans "Script Log" %}</h5>
+  <div class="card">
+    <h5 class="card-header">{% trans "Script Methods" %}</h5>
     <table class="table table-hover">
-      <tr>
-        <th>{% trans "Line" %}</th>
-        <th>{% trans "Level" %}</th>
-        <th>{% trans "Message" %}</th>
-      </tr>
-      {% for log in job.data.log %}
+      {% for method, data in job.data.logs.items %}
         <tr>
-          <td>{{ forloop.counter }}</td>
-          <td>{% log_level log.status %}</td>
-          <td class="rendered-markdown">{{ log.message|markdown }}</td>
-        </tr>
-      {% empty %}
-        <tr>
-          <td colspan="3" class="text-center text-muted">
-            {% trans "No log output" %}
+          <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
+          <td class="text-end script-stats">
+            <span class="badge text-bg-success">{{ data.success }}</span>
+            <span class="badge text-bg-info">{{ data.info }}</span>
+            <span class="badge text-bg-warning">{{ data.warning }}</span>
+            <span class="badge text-bg-danger">{{ data.failure }}</span>
           </td>
         </tr>
       {% endfor %}
     </table>
-    {% if execution_time %}
-      <div class="card-footer text-end text-muted">
-        <small>{% trans "Exec Time" %}: {{ execution_time|floatformat:3 }} {% trans "seconds" context "Unit of time" %}</small>
-      </div>
-    {% endif %}
   </div>
-  <h4>{% trans "Output" %}</h4>
-  {% if job.data.output %}
-    <pre class="block">{{ job.data.output }}</pre>
-  {% else %}
-    <p class="text-muted">{% trans "None" %}</p>
-  {% endif %}
+  <h2 class="card-header">{% trans "Script Results" %}</h2>
+
+  {% for method, data in job.data.logs.items %}
+  <div class="card">
+    <h5 class="card-header"><a name="{{ method }}"></a>{{ method }}</h5>
+    <table class="table table-hover script">
+      <thead>
+        <tr>
+          <th>{% trans "Time" %}</th>
+          <th>{% trans "Level" %}</th>
+          <th>{% trans "Object" %}</th>
+          <th>{% trans "Message" %}</th>
+        </tr>
+      </thead>
+      <tbody>
+          {% for time, level, obj, url, message in data.log %}
+            <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
+              <td>{{ time }}</td>
+              <td>
+                <label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
+              </td>
+              <td>
+                {% if obj and url %}
+                  <a href="{{ url }}">{{ obj }}</a>
+                {% elif obj %}
+                  {{ obj }}
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+              <td class="rendered-markdown">{{ message|markdown }}</td>
+            </tr>
+          {% endfor %}
+      </tbody>
+    </table>
+  </div>
+  {% endfor %}
 {% elif job.started %}
   {% include 'extras/inc/result_pending.html' %}
 {% endif %}
diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html
deleted file mode 100644
index 55df94c3e90..00000000000
--- a/netbox/templates/extras/report.html
+++ /dev/null
@@ -1,43 +0,0 @@
-{% extends 'extras/report/base.html' %}
-{% load helpers %}
-{% load form_helpers %}
-{% load i18n %}
-
-{% block content %}
-  <div role="tabpanel" class="tab-pane active" id="report">
-    {% if perms.extras.run_report %}
-      <div class="row">
-        <div class="col">
-          {% if not report.is_valid %}
-            <div class="alert alert-warning">
-              <i class="mdi mdi-alert"></i>
-              {% trans "This report is invalid and cannot be run." %}
-            </div>
-          {% endif %}
-          <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="object-edit">
-            {% csrf_token %}
-            {% render_form form %}
-            <div class="float-end">
-              <button type="submit" name="_run" class="btn btn-primary"{% if not report.is_valid %} disabled{% endif %}>
-                {% if report.result %}
-                  <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
-                {% else %}
-                  <i class="mdi mdi-play"></i> {% trans "Run Report" %}
-                {% endif %}
-              </button>
-            </div>
-          </form>
-        </div>
-      </div>
-    {% endif %}
-    <div class="row">
-      <div class="col col-md-12">
-        {% if report.result %}
-          {% trans "Last run" %}: <a href="{% url 'extras:report_result' job_pk=report.result.pk %}">
-            <strong>{{ report.result.created|annotated_date }}</strong>
-          </a>
-        {% endif %}
-      </div>
-    </div>
-  </div>
-{% endblock content %}
diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html
deleted file mode 100644
index 97536a4ccde..00000000000
--- a/netbox/templates/extras/report_list.html
+++ /dev/null
@@ -1,128 +0,0 @@
-{% extends 'generic/_base.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load perms %}
-{% load i18n %}
-
-{% block title %}{% trans "Reports" %}{% endblock %}
-
-{% block tabs %}
-  <ul class="nav nav-tabs">
-    <li class="nav-item" role="presentation">
-      <a class="nav-link active" role="tab">{% trans "Reports" %}</a>
-    </li>
-  </ul>
-{% endblock tabs %}
-
-{% block controls %}
-  {% add_button model %}
-{% endblock controls %}
-
-{% block content %}
-  {% for module in report_modules %}
-    <div class="card">
-      <h5 class="card-header justify-content-between" id="module{{ module.pk }}">
-        <div>
-          <i class="mdi mdi-file-document-outline"></i> {{ module }}
-        </div>
-        {% if perms.extras.delete_reportmodule %}
-          <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> {% trans "Delete" %}
-          </a>
-        {% endif %}
-      </h5>
-      <div class="card-body">
-        {% include 'inc/sync_warning.html' with object=module %}
-        {% if module.reports %}
-          <table class="table table-hover reports">
-            <thead>
-              <tr>
-                <th width="250">{% trans "Name" %}</th>
-                <th>{% trans "Description" %}</th>
-                <th>{% trans "Last Run" %}</th>
-                <th>{% trans "Status" %}</th>
-                <th width="120"></th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for report_name, report in module.reports.items %}
-                  {% with last_job=jobs|get_key:report.class_name %}
-                    <tr>
-                      <td>
-                        <a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
-                      </td>
-                      <td>{{ report.description|markdown|placeholder }}</td>
-                      {% if last_job %}
-                        <td>
-                          <a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
-                        </td>
-                        <td>
-                          {% badge last_job.get_status_display last_job.get_status_color %}
-                        </td>
-                      {% else %}
-                        <td class="text-muted">{% trans "Never" %}</td>
-                        <td>
-                          {% if report.is_valid %}
-                            {{ ''|placeholder }}
-                          {% else %}
-                            <span class="badge text-bg-danger" title="{% trans "Report has no test methods" %}">
-                              {% trans "Invalid" %}
-                            </span>
-                          {% endif %}
-                        </td>
-                      {% endif %}
-                      <td>
-                        {% if perms.extras.run_report and report.is_valid %}
-                          <div class="float-end d-print-none">
-                            <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" style="width: 110px">
-                                {% if last_job %}
-                                  <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
-                                {% else %}
-                                  <i class="mdi mdi-play"></i> {% trans "Run Report" %}
-                                {% endif %}
-                              </button>
-                            </form>
-                          </div>
-                        {% endif %}
-                      </td>
-                    </tr>
-                    {% for method, stats in last_job.data.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ method }}</span>
-                        </td>
-                        <td class="text-end text-nowrap report-stats">
-                          <span class="badge text-bg-success">{{ stats.success }}</span>
-                          <span class="badge text-bg-info">{{ stats.info }}</span>
-                          <span class="badge text-bg-warning">{{ stats.warning }}</span>
-                          <span class="badge text-bg-danger">{{ stats.failure }}</span>
-                        </td>
-                      </tr>
-                    {% endfor %}
-                  {% endwith %}
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
-        {% else %}
-          <div class="alert alert-warning" role="alert">
-            <i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
-          </div>
-        {% endif %}
-      </div>
-    </div>
-  {% empty %}
-    <div class="alert alert-info" role="alert">
-      <h4 class="alert-heading">{% trans "No Reports Found" %}</h4>
-      {% if perms.extras.add_reportmodule %}
-        {% url 'extras:reportmodule_add' as create_report_url %}
-        {% blocktrans trimmed %}
-          Get started by <a href="{{ create_report_url }}">creating a report</a> from an uploaded file or data source.
-        {% endblocktrans %}
-      {% endif %}
-    </div>
-  {% endfor %}
-{% endblock content %}
diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html
deleted file mode 100644
index 1db567364d6..00000000000
--- a/netbox/templates/extras/report_result.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends 'extras/report.html' %}
-{% load buttons %}
-{% load perms %}
-
-{% block controls %}
-  {% if request.user|can_delete:job %}
-    {% delete_button job %}
-  {% endif %}
-{% endblock controls %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
-      {% include 'extras/htmx/report_result.html' %}
-    </div>
-  </div>
-{% endblock content %}
\ No newline at end of file
diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html
index bb91f75222b..0c58c1eb355 100644
--- a/netbox/templates/extras/script_list.html
+++ b/netbox/templates/extras/script_list.html
@@ -1,14 +1,11 @@
 {% extends 'generic/_base.html' %}
 {% load buttons %}
 {% load helpers %}
+{% load perms %}
 {% load i18n %}
 
 {% block title %}{% trans "Scripts" %}{% endblock %}
 
-{% block controls %}
-  {% add_button model %}
-{% endblock controls %}
-
 {% block tabs %}
   <ul class="nav nav-tabs">
     <li class="nav-item" role="presentation">
@@ -17,6 +14,10 @@
   </ul>
 {% endblock tabs %}
 
+{% block controls %}
+  {% add_button model %}
+{% endblock controls %}
+
 {% block content %}
   {% for module in script_modules %}
     <div class="card">
@@ -25,65 +26,113 @@ <h5 class="card-header justify-content-between" id="module{{ module.pk }}">
           <i class="mdi mdi-file-document-outline"></i> {{ module }}
         </div>
         {% 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> {% trans "Delete" %}
-            </a>
-          </div>
+          <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> {% trans "Delete" %}
+          </a>
         {% endif %}
       </h5>
       <div class="card-body">
         {% include 'inc/sync_warning.html' with object=module %}
-        {% if not module.scripts %}
-          <div class="alert alert-warning d-flex align-items-center" role="alert">
-            <i class="mdi mdi-alert"></i>
-            {% blocktrans trimmed with file_path=module.full_path %}
-              Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
-            {% endblocktrans %}
-          </div>
-        {% else %}
-          <table class="table table-hover reports">
+        {% if module.scripts %}
+          <table class="table table-hover scripts">
             <thead>
               <tr>
                 <th width="250">{% trans "Name" %}</th>
                 <th>{% trans "Description" %}</th>
                 <th>{% trans "Last Run" %}</th>
-                <th class="text-end">{% trans "Status" %}</th>
+                <th>{% trans "Status" %}</th>
+                <th width="120"></th>
               </tr>
             </thead>
             <tbody>
               {% with jobs=module.get_latest_jobs %}
-                {% for script_name, script_class in module.scripts.items %}
-                  <tr>
-                    <td>
-                      <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
-                    </td>
-                    <td>
-                      {{ script_class.Meta.description|markdown|placeholder }}
-                    </td>
-                    {% with last_result=jobs|get_key:script_class.class_name %}
-                      {% if last_result %}
+                {% for script_name, script in module.scripts.items %}
+                  {% with last_job=jobs|get_key:script.class_name %}
+                    <tr>
+                      <td>
+                        <a href="{% url 'extras:script' module=module.python_name name=script.class_name %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
+                      </td>
+                      <td>{{ script.description|markdown|placeholder }}</td>
+                      {% if last_job %}
                         <td>
-                          <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                          <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
                         </td>
-                        <td class="text-end">
-                          {% badge last_result.get_status_display last_result.get_status_color %}
+                        <td>
+                          {% badge last_job.get_status_display last_job.get_status_color %}
                         </td>
                       {% else %}
                         <td class="text-muted">{% trans "Never" %}</td>
-                        <td class="text-end">{{ ''|placeholder }}</td>
+                        <td>
+                          {% if script.is_valid %}
+                            {{ ''|placeholder }}
+                          {% else %}
+                            <span class="badge text-bg-danger" title="{% trans "Script has no methods" %}">
+                              {% trans "Invalid" %}
+                            </span>
+                          {% endif %}
+                        </td>
                       {% endif %}
-                    {% endwith %}
-                  </tr>
+                      <td>
+                        {% if perms.extras.run_script and script.is_valid %}
+                          <div class="float-end d-print-none">
+                            <form action="{% url 'extras:script' module=script.module name=script.class_name %}" method="post">
+                              {% csrf_token %}
+                              <button type="submit" name="_run" class="btn btn-primary" style="width: 110px">
+                                {% if last_job %}
+                                  <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
+                                {% else %}
+                                  <i class="mdi mdi-play"></i> {% trans "Run Script" %}
+                                {% endif %}
+                              </button>
+                            </form>
+                          </div>
+                        {% endif %}
+                      </td>
+                    </tr>
+                    {% if last_job.data.logs %}
+                      {% for method, stats in last_job.data.logs.items %}
+                        <tr>
+                          <td colspan="4" class="method">
+                            <span class="ps-3">{{ method }}</span>
+                          </td>
+                          <td class="text-end text-nowrap script-stats">
+                            <span class="badge text-bg-success">{{ stats.success }}</span>
+                            <span class="badge text-bg-info">{{ stats.info }}</span>
+                            <span class="badge text-bg-warning">{{ stats.warning }}</span>
+                            <span class="badge text-bg-danger">{{ stats.failure }}</span>
+                          </td>
+                        </tr>
+                      {% endfor %}
+                    {% elif not last_job.data.log %}
+                      {# legacy #}
+                      {% for method, stats in last_job.data.items %}
+                        <tr>
+                          <td colspan="4" class="method">
+                            <span class="ps-3">{{ method }}</span>
+                          </td>
+                          <td class="text-end text-nowrap report-stats">
+                            <span class="badge bg-success">{{ stats.success }}</span>
+                            <span class="badge bg-info">{{ stats.info }}</span>
+                            <span class="badge bg-warning">{{ stats.warning }}</span>
+                            <span class="badge bg-danger">{{ stats.failure }}</span>
+                          </td>
+                        </tr>
+                      {% endfor %}
+                    {% endif %}
+                  {% endwith %}
                 {% endfor %}
               {% endwith %}
             </tbody>
           </table>
+        {% else %}
+          <div class="alert alert-warning" role="alert">
+            <i class="mdi mdi-alert"></i> Could not load scripts from {{ module.name }}
+          </div>
         {% endif %}
       </div>
     </div>
   {% empty %}
-    <div class="alert alert-info">
+    <div class="alert alert-info" role="alert">
       <h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
       {% if perms.extras.add_scriptmodule %}
         {% url 'extras:scriptmodule_add' as create_script_url %}
diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html
index 985c58d634d..02a01149c86 100644
--- a/netbox/templates/extras/script_result.html
+++ b/netbox/templates/extras/script_result.html
@@ -44,7 +44,13 @@
   <div role="tabpanel" class="tab-pane active" id="log">
     <div class="row">
       <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
-        {% include 'extras/htmx/script_result.html' %}
+        {% if legacy_script %}
+          {% include 'extras/htmx/legacy_script_result.html' %}
+        {% elif legacy_report %}
+          {% include 'extras/htmx/legacy_report_result.html' %}
+        {% else %}
+          {% include 'extras/htmx/script_result.html' %}
+        {% endif %}
       </div>
     </div>
   </div>


Code review comments:
path: netbox/core/migrations/0011_job_report_to_script.py
text: @user1:
Can we move this logic to the migration in `extras`? IMO there's no need for it to live in `core`.

diff_hunk: @@ -1343,11 +1163,21 @@ def get(self, request, job_pk):
         module = job.object
         script = module.scripts[job.name]()
 
+        legacy_script = False
+        legacy_report = False
+        if job.data and ('logs' not in job.data):
+            if 'log' in job.data:
+                legacy_script = True
+            else:
+                legacy_report = True
path: netbox/extras/views.py
text: @user1:
I'm not clear on the concept of a legacy script: We should avoid introducing this distinction if at all possible.

@author:
Added in the summary (success, failure, error) of reports to scripts as part of the merge, the old scripts data is in a truncated format so isn't compatible without doing this.

diff_hunk: @@ -446,6 +510,44 @@ def load_json(self, filename):
 
         return data
 
+    def run_test_scripts(self, job):
+        """
+        Run the report and save its results. Each test method will be executed in order.
+        """
+        self.logger.info(f"Running report")
+
+        # Perform any post-run tasks
+        self.pre_run()
path: netbox/extras/scripts.py
text: @user1:
Let's move `pre_run()` and `post_run()` out of this method and into `run()`, to better separate functions.

diff_hunk: @@ -257,7 +257,7 @@ def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwar
 # Scripts
 #
 
-class BaseScript:
+class BaseScript(object):
path: netbox/extras/scripts.py
text: @user1:
```suggestion
class BaseScript:
```

diff_hunk: @@ -500,6 +602,61 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     # Add the current request as a property of the script
     script.request = request
 
+    def signature(fn):
+        """
+        Taken from django.tables2 (https://github.com/jieter/django-tables2/blob/master/django_tables2/utils.py)
path: netbox/extras/scripts.py
text: @user1:
We can't reuse code from other projects. Let's rewrite these functions from scratch to serve our specific needs.

diff_hunk: @@ -446,6 +510,44 @@ def load_json(self, filename):
 
         return data
 
+    def run_test_scripts(self, job):
path: netbox/extras/scripts.py
text: @user1:
Maybe `run_tests()` would be a better name for this? There's only one script involved (the class).

diff_hunk: @@ -67,3 +67,15 @@ def is_report(obj):
         return issubclass(obj, Report) and obj != Report
     except TypeError:
         return False
+
+
+def is_script_or_report(obj):
path: netbox/extras/utils.py
text: @user1:
This name is confusing, as reports will no longer exist with this change in place. IMO we should retain & expand the original `is_script()` function instead of introducing a new function.

Also, the `is_report()` function is now obsolete and can be removed.

diff_hunk: @@ -399,25 +434,54 @@ def as_form(self, data=None, files=None, initial=None):
 
     # Logging
 
-    def log_debug(self, message):
-        self.logger.log(logging.DEBUG, message)
-        self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
+    def _log(self, message, obj=None, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.INFO):
path: netbox/extras/scripts.py
text: @user1:
It's very confusing to pass both `log_level` and `level`. Can't we infer one from the other?

@author:
For legacy reports log function (just plain log not log_debug or log_exception) if we change that from going to [logger.info](http://logger.info/) to logger.debug then I can make a map.  Right now both log and log_debug use LogLevelChoices.LOG_DEFAULT but log goes to info and log_debug goes to debug so there isn't a 1:1 mapping, but if we can change this then can make one map to the other.