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

Closes #12068: Establish a direct relationship from jobs to objects (#12075)

* Reference database object by GFK when running scripts & reports via UI

* Reference database object by GFK when running scripts & reports via API

* Remove old enqueue_job() method

* Enable filtering jobs by object

* Introduce ObjectJobsView

* Add tabbed views for report & script jobs

* Add object_id to JobSerializer

* Move generic relation to JobsMixin

* Clean up old naming
Jeremy Stretch 2 лет назад
Родитель
Сommit
d2a694a878
33 измененных файлов с 583 добавлено и 353 удалено
  1. 2 2
      netbox/core/api/serializers.py
  2. 1 1
      netbox/core/filtersets.py
  3. 5 6
      netbox/core/jobs.py
  4. 6 8
      netbox/core/models/data.py
  5. 24 22
      netbox/core/models/jobs.py
  6. 9 4
      netbox/core/tables/jobs.py
  7. 2 2
      netbox/core/views.py
  8. 33 29
      netbox/extras/api/views.py
  9. 3 6
      netbox/extras/management/commands/runreport.py
  10. 20 23
      netbox/extras/management/commands/runscript.py
  11. 3 2
      netbox/extras/models/reports.py
  12. 3 2
      netbox/extras/models/scripts.py
  13. 31 27
      netbox/extras/reports.py
  14. 27 28
      netbox/extras/scripts.py
  15. 20 6
      netbox/extras/tests/test_api.py
  16. 5 2
      netbox/extras/urls.py
  17. 118 38
      netbox/extras/views.py
  18. 12 0
      netbox/netbox/models/features.py
  19. 56 0
      netbox/netbox/views/generic/feature_views.py
  20. 15 0
      netbox/templates/core/object_jobs.html
  21. 12 12
      netbox/templates/extras/htmx/report_result.html
  22. 13 13
      netbox/templates/extras/htmx/script_result.html
  23. 2 31
      netbox/templates/extras/report.html
  24. 35 0
      netbox/templates/extras/report/base.html
  25. 15 0
      netbox/templates/extras/report/jobs.html
  26. 2 2
      netbox/templates/extras/report_list.html
  27. 3 3
      netbox/templates/extras/report_result.html
  28. 41 77
      netbox/templates/extras/script.html
  29. 37 0
      netbox/templates/extras/script/base.html
  30. 15 0
      netbox/templates/extras/script/jobs.html
  31. 6 0
      netbox/templates/extras/script/source.html
  32. 2 2
      netbox/templates/extras/script_list.html
  33. 5 5
      netbox/templates/extras/script_result.html

+ 2 - 2
netbox/core/api/serializers.py

@@ -67,6 +67,6 @@ class JobSerializer(BaseModelSerializer):
     class Meta:
         model = Job
         fields = [
-            'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
-            'object_type', 'user', 'data', 'job_id',
+            'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
+            'started', 'completed', 'user', 'data', 'job_id',
         ]

+ 1 - 1
netbox/core/filtersets.py

@@ -113,7 +113,7 @@ class JobFilterSet(BaseFilterSet):
 
     class Meta:
         model = Job
-        fields = ('id', 'interval', 'status', 'user', 'object_type', 'name')
+        fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user')
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 5 - 6
netbox/core/jobs.py

@@ -1,6 +1,5 @@
 import logging
 
-from .choices import JobStatusChoices
 from netbox.search.backends import search_backend
 from .choices import *
 from .exceptions import SyncError
@@ -9,22 +8,22 @@ from .models import DataSource
 logger = logging.getLogger(__name__)
 
 
-def sync_datasource(job_result, *args, **kwargs):
+def sync_datasource(job, *args, **kwargs):
     """
     Call sync() on a DataSource.
     """
-    datasource = DataSource.objects.get(name=job_result.name)
+    datasource = DataSource.objects.get(pk=job.object_id)
 
     try:
-        job_result.start()
+        job.start()
         datasource.sync()
 
         # Update the search cache for DataFiles belonging to this source
         search_backend.cache(datasource.datafiles.iterator())
 
-        job_result.terminate()
+        job.terminate()
 
     except SyncError as e:
-        job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
+        job.terminate(status=JobStatusChoices.STATUS_ERRORED)
         DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
         logging.error(e)

+ 6 - 8
netbox/core/models/data.py

@@ -5,7 +5,7 @@ from fnmatch import fnmatchcase
 from urllib.parse import urlparse
 
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.db import models
@@ -15,6 +15,7 @@ from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 
 from netbox.models import PrimaryModel
+from netbox.models.features import JobsMixin
 from netbox.registry import registry
 from utilities.files import sha256_hash
 from utilities.querysets import RestrictedQuerySet
@@ -31,7 +32,7 @@ __all__ = (
 logger = logging.getLogger('netbox.core.data')
 
 
-class DataSource(PrimaryModel):
+class DataSource(JobsMixin, PrimaryModel):
     """
     A remote source, such as a git repository, from which DataFiles are synchronized.
     """
@@ -118,15 +119,12 @@ class DataSource(PrimaryModel):
         DataSource.objects.filter(pk=self.pk).update(status=self.status)
 
         # Enqueue a sync job
-        job_result = Job.enqueue_job(
+        return Job.enqueue(
             import_string('core.jobs.sync_datasource'),
-            name=self.name,
-            obj_type=ContentType.objects.get_for_model(DataSource),
-            user=request.user,
+            instance=self,
+            user=request.user
         )
 
-        return job_result
-
     def get_backend(self):
         backend_cls = registry['data_backends'].get(self.type)
         backend_params = self.parameters or {}

+ 24 - 22
netbox/core/models/jobs.py

@@ -7,7 +7,6 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MinValueValidator
 from django.db import models
 from django.urls import reverse
-from django.urls.exceptions import NoReverseMatch
 from django.utils import timezone
 from django.utils.translation import gettext as _
 
@@ -96,21 +95,12 @@ class Job(models.Model):
     def __str__(self):
         return str(self.job_id)
 
-    def delete(self, *args, **kwargs):
-        super().delete(*args, **kwargs)
-
-        rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
-        queue = django_rq.get_queue(rq_queue_name)
-        job = queue.fetch_job(str(self.job_id))
-
-        if job:
-            job.cancel()
-
     def get_absolute_url(self):
-        try:
-            return reverse(f'extras:{self.object_type.model}_result', args=[self.pk])
-        except NoReverseMatch:
-            return None
+        # TODO: Employ dynamic registration
+        if self.object_type.model == 'reportmodule':
+            return reverse(f'extras:report_result', kwargs={'job_pk': self.pk})
+        if self.object_type.model == 'scriptmodule':
+            return reverse(f'extras:script_result', kwargs={'job_pk': self.pk})
 
     def get_status_color(self):
         return JobStatusChoices.colors.get(self.status)
@@ -130,6 +120,16 @@ class Job(models.Model):
 
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"
 
+    def delete(self, *args, **kwargs):
+        super().delete(*args, **kwargs)
+
+        rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
+        queue = django_rq.get_queue(rq_queue_name)
+        job = queue.fetch_job(str(self.job_id))
+
+        if job:
+            job.cancel()
+
     def start(self):
         """
         Record the job's start time and update its status to "running."
@@ -162,25 +162,27 @@ class Job(models.Model):
         self.trigger_webhooks(event=EVENT_JOB_END)
 
     @classmethod
-    def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
+    def enqueue(cls, func, instance, name='', user=None, schedule_at=None, interval=None, **kwargs):
         """
         Create a Job instance and enqueue a job using the given callable
 
         Args:
             func: The callable object to be enqueued for execution
+            instance: The NetBox object to which this job pertains
             name: Name for the job (optional)
-            obj_type: ContentType to link to the Job instance object_type
-            user: User object to link to the Job instance
+            user: The user responsible for running the job
             schedule_at: Schedule the job to be executed at the passed date and time
             interval: Recurrence interval (in minutes)
         """
-        rq_queue_name = get_queue_for_model(obj_type.model)
+        object_type = ContentType.objects.get_for_model(instance, for_concrete_model=False)
+        rq_queue_name = get_queue_for_model(object_type.model)
         queue = django_rq.get_queue(rq_queue_name)
         status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
         job = Job.objects.create(
+            object_type=object_type,
+            object_id=instance.pk,
             name=name,
             status=status,
-            object_type=obj_type,
             scheduled=schedule_at,
             interval=interval,
             user=user,
@@ -188,9 +190,9 @@ class Job(models.Model):
         )
 
         if schedule_at:
-            queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job_result=job, **kwargs)
+            queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
         else:
-            queue.enqueue(func, job_id=str(job.job_id), job_result=job, **kwargs)
+            queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
 
         return job
 

+ 9 - 4
netbox/core/tables/jobs.py

@@ -6,12 +6,18 @@ from ..models import Job
 
 
 class JobTable(NetBoxTable):
+    id = tables.Column(
+        linkify=True
+    )
     name = tables.Column(
         linkify=True
     )
     object_type = columns.ContentTypeColumn(
         verbose_name=_('Type')
     )
+    object = tables.Column(
+        linkify=True
+    )
     status = columns.ChoiceFieldColumn()
     created = columns.DateTimeColumn()
     scheduled = columns.DateTimeColumn()
@@ -25,10 +31,9 @@ class JobTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Job
         fields = (
-            'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
-            'user', 'job_id',
+            'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
+            'completed', 'user', 'job_id',
         )
         default_columns = (
-            'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
-            'user',
+            'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
         )

+ 2 - 2
netbox/core/views.py

@@ -55,9 +55,9 @@ class DataSourceSyncView(BaseObjectView):
 
     def post(self, request, pk):
         datasource = get_object_or_404(self.queryset, pk=pk)
-        job_result = datasource.enqueue_sync_job(request)
+        job = datasource.enqueue_sync_job(request)
 
-        messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}")
+        messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
         return redirect(datasource.get_absolute_url())
 
 

+ 33 - 29
netbox/extras/api/views.py

@@ -1,5 +1,6 @@
 from django.contrib.contenttypes.models import ContentType
 from django.http import Http404
+from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework.decorators import action
@@ -16,8 +17,8 @@ from core.choices import JobStatusChoices
 from core.models import Job
 from extras import filtersets
 from extras.models import *
-from extras.reports import get_report, run_report
-from extras.scripts import get_script, run_script
+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
 from netbox.api.metadata import ContentTypeMetadata
@@ -170,19 +171,17 @@ class ReportViewSet(ViewSet):
     exclude_from_schema = True
     lookup_value_regex = '[^/]+'  # Allow dots
 
-    def _retrieve_report(self, pk):
-
-        # Read the PK as "<module>.<report>"
-        if '.' not in pk:
+    def _get_report(self, pk):
+        try:
+            module_name, report_name = pk.split('.', maxsplit=1)
+        except ValueError:
             raise Http404
-        module_name, report_name = pk.split('.', maxsplit=1)
 
-        # Raise a 404 on an invalid Report module/name
-        report = get_report(module_name, report_name)
+        module, report = get_module_and_report(module_name, report_name)
         if report is None:
             raise Http404
 
-        return report
+        return module, report
 
     def list(self, request):
         """
@@ -215,13 +214,13 @@ class ReportViewSet(ViewSet):
         """
         Retrieve a single Report identified as "<module>.<report>".
         """
+        module, report = self._get_report(pk)
 
         # Retrieve the Report and Job, if any.
-        report = self._retrieve_report(pk)
-        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
         report.result = Job.objects.filter(
-            object_type=report_content_type,
-            name=report.full_name,
+            object_type=object_type,
+            name=report.name,
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
 
@@ -245,14 +244,14 @@ class ReportViewSet(ViewSet):
             raise RQWorkerNotRunningException()
 
         # Retrieve and run the Report. This will create a new Job.
-        report = self._retrieve_report(pk)
+        module, report = self._get_report(pk)
         input_serializer = serializers.ReportInputSerializer(data=request.data)
 
         if input_serializer.is_valid():
-            report.result = Job.enqueue_job(
+            report.result = Job.enqueue(
                 run_report,
-                name=report.full_name,
-                obj_type=ContentType.objects.get_for_model(Report),
+                instance=module,
+                name=report.class_name,
                 user=request.user,
                 job_timeout=report.job_timeout,
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
@@ -275,11 +274,16 @@ class ScriptViewSet(ViewSet):
     lookup_value_regex = '[^/]+'  # Allow dots
 
     def _get_script(self, pk):
-        module_name, script_name = pk.split('.', maxsplit=1)
-        script = get_script(module_name, script_name)
+        try:
+            module_name, script_name = pk.split('.', maxsplit=1)
+        except ValueError:
+            raise Http404
+
+        module, script = get_module_and_script(module_name, script_name)
         if script is None:
             raise Http404
-        return script
+
+        return module, script
 
     def list(self, request):
 
@@ -305,11 +309,11 @@ class ScriptViewSet(ViewSet):
         return Response(serializer.data)
 
     def retrieve(self, request, pk):
-        script = self._get_script(pk)
-        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+        module, script = self._get_script(pk)
+        object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
         script.result = Job.objects.filter(
-            object_type=script_content_type,
-            name=script.full_name,
+            object_type=object_type,
+            name=script.name,
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
@@ -324,7 +328,7 @@ class ScriptViewSet(ViewSet):
         if not request.user.has_perm('extras.run_script'):
             raise PermissionDenied("This user does not have permission to run scripts.")
 
-        script = self._get_script(pk)()
+        module, script = self._get_script(pk)
         input_serializer = serializers.ScriptInputSerializer(data=request.data)
 
         # Check that at least one RQ worker is running
@@ -332,10 +336,10 @@ class ScriptViewSet(ViewSet):
             raise RQWorkerNotRunningException()
 
         if input_serializer.is_valid():
-            script.result = Job.enqueue_job(
+            script.result = Job.enqueue(
                 run_script,
-                name=script.full_name,
-                obj_type=ContentType.objects.get_for_model(Script),
+                instance=module,
+                name=script.class_name,
                 user=request.user,
                 data=input_serializer.data['data'],
                 request=copy_safe_request(request),

+ 3 - 6
netbox/extras/management/commands/runreport.py

@@ -1,6 +1,5 @@
 import time
 
-from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 
@@ -27,12 +26,10 @@ class Command(BaseCommand):
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                     )
 
-                    report_content_type = ContentType.objects.get(app_label='extras', model='report')
-                    job = Job.enqueue_job(
+                    job = Job.enqueue(
                         run_report,
-                        report.full_name,
-                        report_content_type,
-                        None,
+                        instance=module,
+                        name=report.class_name,
                         job_timeout=report.job_timeout
                     )
 

+ 20 - 23
netbox/extras/management/commands/runscript.py

@@ -5,7 +5,6 @@ import traceback
 import uuid
 
 from django.contrib.auth.models import User
-from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand, CommandError
 from django.db import transaction
 
@@ -13,7 +12,7 @@ from core.choices import JobStatusChoices
 from core.models import Job
 from extras.api.serializers import ScriptOutputSerializer
 from extras.context_managers import change_logging
-from extras.scripts import get_script
+from extras.scripts import get_module_and_script
 from extras.signals import clear_webhooks
 from utilities.exceptions import AbortTransaction
 from utilities.utils import NetBoxFakeRequest
@@ -49,8 +48,8 @@ class Command(BaseCommand):
                 except AbortTransaction:
                     script.log_info("Database changes have been reverted automatically.")
                     clear_webhooks.send(request)
-                job_result.data = ScriptOutputSerializer(script).data
-                job_result.terminate()
+                job.data = ScriptOutputSerializer(script).data
+                job.terminate()
             except Exception as e:
                 stacktrace = traceback.format_exc()
                 script.log_failure(
@@ -59,10 +58,10 @@ class Command(BaseCommand):
                 script.log_info("Database changes have been reverted due to error.")
                 logger.error(f"Exception raised during script execution: {e}")
                 clear_webhooks.send(request)
-                job_result.data = ScriptOutputSerializer(script).data
-                job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
+                job.data = ScriptOutputSerializer(script).data
+                job.terminate(status=JobStatusChoices.STATUS_ERRORED)
 
-            logger.info(f"Script completed in {job_result.duration}")
+            logger.info(f"Script completed in {job.duration}")
 
         # Params
         script = options['script']
@@ -73,7 +72,8 @@ class Command(BaseCommand):
         except TypeError:
             data = {}
 
-        module, name = script.split('.', 1)
+        module_name, script_name = script.split('.', 1)
+        module, script = get_module_and_script(module_name, script_name)
 
         # Take user from command line if provided and exists, other
         if options['user']:
@@ -90,7 +90,7 @@ class Command(BaseCommand):
         stdouthandler.setLevel(logging.DEBUG)
         stdouthandler.setFormatter(formatter)
 
-        logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
+        logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
         logger.addHandler(stdouthandler)
 
         try:
@@ -105,17 +105,14 @@ class Command(BaseCommand):
         except KeyError:
             raise CommandError(f"Invalid log level: {loglevel}")
 
-        # Get the script
-        script = get_script(module, name)()
-        # Parse the parameters
+        # Initialize the script form
+        script = script()
         form = script.as_form(data, None)
 
-        script_content_type = ContentType.objects.get(app_label='extras', model='script')
-
-        # Create the job result
-        job_result = Job.objects.create(
-            name=script.full_name,
-            obj_type=script_content_type,
+        # Create the job
+        job = Job.objects.create(
+            instance=module,
+            name=script.name,
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             job_id=uuid.uuid4()
         )
@@ -127,12 +124,12 @@ class Command(BaseCommand):
             'FILES': {},
             'user': user,
             'path': '',
-            'id': job_result.job_id
+            'id': job.job_id
         })
 
         if form.is_valid():
-            job_result.status = JobStatusChoices.STATUS_RUNNING
-            job_result.save()
+            job.status = JobStatusChoices.STATUS_RUNNING
+            job.save()
 
             logger.info(f"Running script (commit={commit})")
             script.request = request
@@ -146,5 +143,5 @@ class Command(BaseCommand):
             for field, errors in form.errors.get_json_data().items():
                 for error in errors:
                     logger.error(f'\t{field}: {error.get("message")}')
-            job_result.status = JobStatusChoices.STATUS_ERRORED
-            job_result.save()
+            job.status = JobStatusChoices.STATUS_ERRORED
+            job.save()

+ 3 - 2
netbox/extras/models/reports.py

@@ -1,6 +1,7 @@
 import inspect
 from functools import cached_property
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.urls import reverse
 
@@ -17,7 +18,7 @@ __all__ = (
 )
 
 
-class Report(JobsMixin, WebhooksMixin, models.Model):
+class Report(WebhooksMixin, models.Model):
     """
     Dummy model used to generate permissions for reports. Does not exist in the database.
     """
@@ -31,7 +32,7 @@ class ReportModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
         return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
 
 
-class ReportModule(PythonModuleMixin, ManagedFile):
+class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
     """
     Proxy model for report module files.
     """

+ 3 - 2
netbox/extras/models/scripts.py

@@ -1,6 +1,7 @@
 import inspect
 from functools import cached_property
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.urls import reverse
 
@@ -17,7 +18,7 @@ __all__ = (
 )
 
 
-class Script(JobsMixin, WebhooksMixin, models.Model):
+class Script(WebhooksMixin, models.Model):
     """
     Dummy model used to generate permissions for custom scripts. Does not exist in the database.
     """
@@ -31,7 +32,7 @@ class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
         return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
 
 
-class ScriptModule(PythonModuleMixin, ManagedFile):
+class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
     """
     Proxy model for script module files.
     """

+ 31 - 27
netbox/extras/reports.py

@@ -11,45 +11,49 @@ from core.models import Job
 from .choices import LogLevelChoices
 from .models import ReportModule
 
+__all__ = (
+    'Report',
+    'get_module_and_report',
+    'run_report',
+)
+
 logger = logging.getLogger(__name__)
 
 
-def get_report(module_name, report_name):
-    """
-    Return a specific report from within a module.
-    """
+def get_module_and_report(module_name, report_name):
     module = ReportModule.objects.get(file_path=f'{module_name}.py')
-    return module.reports.get(report_name)
+    report = module.reports.get(report_name)
+    return module, report
 
 
 @job('default')
-def run_report(job_result, *args, **kwargs):
+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.
     """
-    module_name, report_name = job_result.name.split('.', 1)
-    report = get_report(module_name, report_name)()
+    job.start()
+
+    module = ReportModule.objects.get(pk=job.object_id)
+    report = module.reports.get(job.name)()
 
     try:
-        job_result.start()
-        report.run(job_result)
+        report.run(job)
     except Exception:
-        job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
-        logging.error(f"Error during execution of report {job_result.name}")
+        job.terminate(status=JobStatusChoices.STATUS_ERRORED)
+        logging.error(f"Error during execution of report {job.name}")
     finally:
         # Schedule the next job if an interval has been set
-        start_time = job_result.scheduled or job_result.started
-        if start_time and job_result.interval:
-            new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
-            Job.enqueue_job(
+        if job.interval:
+            new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
+            Job.enqueue(
                 run_report,
-                name=job_result.name,
-                obj_type=job_result.obj_type,
-                user=job_result.user,
+                instance=job.object,
+                name=job.name,
+                user=job.user,
                 job_timeout=report.job_timeout,
                 schedule_at=new_scheduled_time,
-                interval=job_result.interval
+                interval=job.interval
             )
 
 
@@ -186,13 +190,13 @@ class Report(object):
     # Run methods
     #
 
-    def run(self, job_result):
+    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")
-        job_result.status = JobStatusChoices.STATUS_RUNNING
-        job_result.save()
+        job.status = JobStatusChoices.STATUS_RUNNING
+        job.save()
 
         # Perform any post-run tasks
         self.pre_run()
@@ -204,17 +208,17 @@ class Report(object):
                 test_method()
             if self.failed:
                 self.logger.warning("Report failed")
-                job_result.status = JobStatusChoices.STATUS_FAILED
+                job.status = JobStatusChoices.STATUS_FAILED
             else:
                 self.logger.info("Report completed successfully")
-                job_result.status = JobStatusChoices.STATUS_COMPLETED
+                job.status = JobStatusChoices.STATUS_COMPLETED
         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_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
+            job.terminate(status=JobStatusChoices.STATUS_ERRORED)
         finally:
-            job_result.terminate()
+            job.terminate()
 
         # Perform any post-run tasks
         self.post_run()

+ 27 - 28
netbox/extras/scripts.py

@@ -25,7 +25,7 @@ from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicMo
 from .context_managers import change_logging
 from .forms import ScriptForm
 
-__all__ = [
+__all__ = (
     'BaseScript',
     'BooleanVar',
     'ChoiceVar',
@@ -40,7 +40,9 @@ __all__ = [
     'Script',
     'StringVar',
     'TextVar',
-]
+    'get_module_and_script',
+    'run_script',
+)
 
 
 #
@@ -436,18 +438,23 @@ def is_variable(obj):
     return isinstance(obj, ScriptVariable)
 
 
-def run_script(data, request, commit=True, *args, **kwargs):
+def get_module_and_script(module_name, script_name):
+    module = ScriptModule.objects.get(file_path=f'{module_name}.py')
+    script = module.scripts.get(script_name)
+    return module, script
+
+
+def run_script(data, request, job, commit=True, **kwargs):
     """
     A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
     exists outside the Script class to ensure it cannot be overridden by a script author.
     """
-    job_result = kwargs.pop('job_result')
-    job_result.start()
+    job.start()
 
-    module_name, script_name = job_result.name.split('.', 1)
-    script = get_script(module_name, script_name)()
+    module = ScriptModule.objects.get(pk=job.object_id)
+    script = module.scripts.get(job.name)()
 
-    logger = logging.getLogger(f"netbox.scripts.{module_name}.{script_name}")
+    logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
     logger.info(f"Running script (commit={commit})")
 
     # Add files to form data
@@ -472,8 +479,8 @@ def run_script(data, request, commit=True, *args, **kwargs):
             except AbortTransaction:
                 script.log_info("Database changes have been reverted automatically.")
                 clear_webhooks.send(request)
-            job_result.data = ScriptOutputSerializer(script).data
-            job_result.terminate()
+            job.data = ScriptOutputSerializer(script).data
+            job.terminate()
         except Exception as e:
             if type(e) is AbortScript:
                 script.log_failure(f"Script aborted with error: {e}")
@@ -483,11 +490,11 @@ def run_script(data, request, commit=True, *args, **kwargs):
                 script.log_failure(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_result.data = ScriptOutputSerializer(script).data
-            job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
+            job.data = ScriptOutputSerializer(script).data
+            job.terminate(status=JobStatusChoices.STATUS_ERRORED)
             clear_webhooks.send(request)
 
-        logger.info(f"Script completed in {job_result.duration}")
+        logger.info(f"Script completed in {job.duration}")
 
     # Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
     # change logging, webhooks, etc.
@@ -498,25 +505,17 @@ def run_script(data, request, commit=True, *args, **kwargs):
         _run_script()
 
     # Schedule the next job if an interval has been set
-    if job_result.interval:
-        new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
-        Job.enqueue_job(
+    if job.interval:
+        new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
+        Job.enqueue(
             run_script,
-            name=job_result.name,
-            obj_type=job_result.obj_type,
-            user=job_result.user,
+            instance=job.object,
+            name=job.name,
+            user=job.user,
             schedule_at=new_scheduled_time,
-            interval=job_result.interval,
+            interval=job.interval,
             job_timeout=script.job_timeout,
             data=data,
             request=request,
             commit=commit
         )
-
-
-def get_script(module_name, script_name):
-    """
-    Retrieve a script class by module and name. Returns None if the script does not exist.
-    """
-    module = ScriptModule.objects.get(file_path=f'{module_name}.py')
-    return module.scripts.get(script_name)

+ 20 - 6
netbox/extras/tests/test_api.py

@@ -9,6 +9,7 @@ from django_rq.queues import get_connection
 from rest_framework import status
 from rq import Worker
 
+from core.choices import ManagedFileRootPathChoices
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.api.views import ReportViewSet, ScriptViewSet
 from extras.models import *
@@ -524,14 +525,21 @@ class ReportTest(APITestCase):
         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 self.TestReport()
+        return ReportModule.objects.first(), self.TestReport()
 
     def setUp(self):
         super().setUp()
 
-        # Monkey-patch the API viewset's _get_script method to return our test script above
-        ReportViewSet._retrieve_report = self.get_test_report
+        # Monkey-patch the API viewset's _get_report() method to return our test Report above
+        ReportViewSet._get_report = self.get_test_report
 
     def test_get_report(self):
         url = reverse('extras-api:report-detail', kwargs={'pk': None})
@@ -569,14 +577,20 @@ class ScriptTest(APITestCase):
 
             return 'Script complete'
 
+    @classmethod
+    def setUpTestData(cls):
+        ScriptModule.objects.create(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path='/var/tmp/script.py'
+        )
+
     def get_test_script(self, *args):
-        return self.TestScript
+        return ScriptModule.objects.first(), self.TestScript
 
     def setUp(self):
-
         super().setUp()
 
-        # Monkey-patch the API viewset's _get_script method to return our test script above
+        # Monkey-patch the API viewset's _get_script() method to return our test Script above
         ScriptViewSet._get_script = self.get_test_script
 
     def test_get_script(self):

+ 5 - 2
netbox/extras/urls.py

@@ -95,16 +95,19 @@ 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'),
+    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/<path:module>.<str:name>/', views.ReportView.as_view(), name='report'),
+    path('reports/<path: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'),
-    path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
+    path('scripts/results/<int:job_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'),
+    path('scripts/<path:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
+    path('scripts/<path:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
 
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")

+ 118 - 38
netbox/extras/views.py

@@ -10,6 +10,7 @@ from django.views.generic import View
 from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.forms import ManagedFileForm
 from core.models import Job
+from core.tables import JobTable
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from netbox.views import generic
@@ -22,7 +23,7 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v
 from . import filtersets, forms, tables
 from .forms.reports import ReportForm
 from .models import *
-from .reports import get_report, run_report
+from .reports import run_report
 from .scripts import run_script
 
 
@@ -819,7 +820,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         report_modules = ReportModule.objects.restrict(request.user)
 
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        job_results = {
+        jobs = {
             r.name: r
             for r in Job.objects.filter(
                 object_type=report_content_type,
@@ -830,7 +831,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         return render(request, 'extras/report_list.html', {
             'model': ReportModule,
             'report_modules': report_modules,
-            'job_results': job_results,
+            'jobs': jobs,
         })
 
 
@@ -845,10 +846,11 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         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')
+        object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
         report.result = Job.objects.filter(
-            object_type=report_content_type,
-            name=report.full_name,
+            object_type=object_type,
+            object_id=module.pk,
+            name=report.name,
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
 
@@ -876,17 +878,17 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
                 })
 
             # Run the Report. A new Job is created.
-            job_result = Job.enqueue_job(
+            job = Job.enqueue(
                 run_report,
-                name=report.full_name,
-                obj_type=ContentType.objects.get_for_model(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_result_pk=job_result.pk)
+            return redirect('extras:report_result', job_pk=job.pk)
 
         return render(request, 'extras/report.html', {
             'module': module,
@@ -895,6 +897,38 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         })
 
 
+class ReportJobsView(ContentTypePermissionRequiredMixin, View):
+
+    def get_required_permission(self):
+        return 'extras.view_report'
+
+    def get(self, request, module, name):
+        module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path=f'{module}.py')
+        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.name,
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
+        )
+
+        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.
@@ -902,28 +936,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
     def get_required_permission(self):
         return 'extras.view_report'
 
-    def get(self, request, job_result_pk):
-        report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=report_content_type)
+    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)
 
-        # Retrieve the Report and attach the Job to it
-        module, report_name = result.name.split('.', maxsplit=1)
-        report = get_report(module, report_name)
-        report.result = result
+        module = job.object
+        report = module.reports[job.name]
 
         # If this is an HTMX request, return only the result HTML
         if is_htmx(request):
             response = render(request, 'extras/htmx/report_result.html', {
                 'report': report,
-                'result': result,
+                'job': job,
             })
-            if result.completed or not result.started:
+            if job.completed or not job.started:
                 response.status_code = 286
             return response
 
         return render(request, 'extras/report_result.html', {
             'report': report,
-            'result': result,
+            'job': job,
         })
 
 
@@ -956,7 +988,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         script_modules = ScriptModule.objects.restrict(request.user)
 
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
-        job_results = {
+        jobs = {
             r.name: r
             for r in Job.objects.filter(
                 object_type=script_content_type,
@@ -967,7 +999,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         return render(request, 'extras/script_list.html', {
             'model': ScriptModule,
             'script_modules': script_modules,
-            'job_results': job_results,
+            'jobs': jobs,
         })
 
 
@@ -982,9 +1014,11 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
         form = script.as_form(initial=normalize_querydict(request.GET))
 
         # Look for a pending Job (use the latest one by creation timestamp)
+        object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
         script.result = Job.objects.filter(
-            object_type=ContentType.objects.get_for_model(Script),
-            name=script.full_name,
+            object_type=object_type,
+            object_id=module.pk,
+            name=script.name,
         ).exclude(
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
@@ -1008,10 +1042,10 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
             messages.error(request, "Unable to run script: RQ worker process not running.")
 
         elif form.is_valid():
-            job_result = Job.enqueue_job(
+            job = Job.enqueue(
                 run_script,
-                name=script.full_name,
-                obj_type=ContentType.objects.get_for_model(Script),
+                instance=module,
+                name=script.class_name,
                 user=request.user,
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 interval=form.cleaned_data.pop('_interval'),
@@ -1021,7 +1055,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
                 commit=form.cleaned_data.pop('_commit')
             )
 
-            return redirect('extras:script_result', job_result_pk=job_result.pk)
+            return redirect('extras:script_result', job_pk=job.pk)
 
         return render(request, 'extras/script.html', {
             'module': module,
@@ -1030,33 +1064,79 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
         })
 
 
+class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
+
+    def get_required_permission(self):
+        return 'extras.view_script'
+
+    def get(self, request, module, name):
+        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module}.py')
+        script = module.scripts[name]()
+
+        return render(request, 'extras/script/source.html', {
+            'module': module,
+            'script': script,
+            'tab': 'source',
+        })
+
+
+class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
+
+    def get_required_permission(self):
+        return 'extras.view_script'
+
+    def get(self, request, module, name):
+        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module}.py')
+        script = module.scripts[name]()
+
+        object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
+        jobs = Job.objects.filter(
+            object_type=object_type,
+            object_id=module.pk,
+            name=script.class_name,
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
+        )
+
+        jobs_table = JobTable(
+            data=jobs,
+            orderable=False,
+            user=request.user
+        )
+        jobs_table.configure(request)
+
+        return render(request, 'extras/script/jobs.html', {
+            'module': module,
+            'script': script,
+            'table': jobs_table,
+            'tab': 'jobs',
+        })
+
+
 class ScriptResultView(ContentTypePermissionRequiredMixin, View):
 
     def get_required_permission(self):
         return 'extras.view_script'
 
-    def get(self, request, job_result_pk):
-        script_content_type = ContentType.objects.get(app_label='extras', model='script')
-        result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=script_content_type)
+    def get(self, request, job_pk):
+        object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='scriptmodule')
+        job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
 
-        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]()
+        module = job.object
+        script = module.scripts[job.name]()
 
         # If this is an HTMX request, return only the result HTML
         if is_htmx(request):
             response = render(request, 'extras/htmx/script_result.html', {
                 'script': script,
-                'result': result,
+                'job': job,
             })
-            if result.completed or not result.started:
+            if job.completed or not job.started:
                 response.status_code = 286
             return response
 
         return render(request, 'extras/script_result.html', {
             'script': script,
-            'result': result,
-            'class_name': script.__class__.__name__
+            'job': job,
         })
 
 

+ 12 - 0
netbox/netbox/models/features.py

@@ -299,6 +299,12 @@ class JobsMixin(models.Model):
     """
     Enables support for job results.
     """
+    jobs = GenericRelation(
+        to='core.Job',
+        content_type_field='object_type',
+        object_id_field='object_id'
+    )
+
     class Meta:
         abstract = True
 
@@ -455,6 +461,12 @@ def _register_features(sender, **kwargs):
             'changelog',
             kwargs={'model': sender}
         )('netbox.views.generic.ObjectChangeLogView')
+    if issubclass(sender, JobsMixin):
+        register_model_view(
+            sender,
+            'jobs',
+            kwargs={'model': sender}
+        )('netbox.views.generic.ObjectJobsView')
     if issubclass(sender, SyncedDataMixin):
         register_model_view(
             sender,

+ 56 - 0
netbox/netbox/views/generic/feature_views.py

@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.translation import gettext as _
 from django.views.generic import View
 
+from core.models import Job
+from core.tables import JobTable
 from extras import forms, tables
 from extras.models import *
 from utilities.permissions import get_permission_for_model
@@ -15,6 +17,7 @@ from .base import BaseMultiObjectView
 __all__ = (
     'BulkSyncDataView',
     'ObjectChangeLogView',
+    'ObjectJobsView',
     'ObjectJournalView',
     'ObjectSyncDataView',
 )
@@ -134,6 +137,59 @@ class ObjectJournalView(View):
         })
 
 
+class ObjectJobsView(View):
+    """
+    Render a list of all Job assigned to an object. For example:
+
+        path('data-sources/<int:pk>/jobs/', ObjectJobsView.as_view(), name='datasource_jobs', kwargs={'model': DataSource}),
+
+    Attributes:
+        base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
+    """
+    base_template = None
+    tab = ViewTab(
+        label=_('Jobs'),
+        badge=lambda obj: obj.jobs.count(),
+        permission='core.view_job',
+        weight=11000
+    )
+
+    def get_object(self, request, **kwargs):
+        return get_object_or_404(self.model.objects.restrict(request.user, 'view'), **kwargs)
+
+    def get_jobs(self, instance):
+        object_type = ContentType.objects.get_for_model(instance)
+        return Job.objects.filter(
+            object_type=object_type,
+            object_id=instance.id
+        )
+
+    def get(self, request, model, **kwargs):
+        self.model = model
+        obj = self.get_object(request, **kwargs)
+
+        # Gather all Jobs for this object
+        jobs = self.get_jobs(obj)
+        jobs_table = JobTable(
+            data=jobs,
+            orderable=False,
+            user=request.user
+        )
+        jobs_table.configure(request)
+
+        # Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
+        # fall back to using base.html.
+        if self.base_template is None:
+            self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
+
+        return render(request, 'core/object_jobs.html', {
+            'object': obj,
+            'table': jobs_table,
+            'base_template': self.base_template,
+            'tab': self.tab,
+        })
+
+
 class ObjectSyncDataView(View):
 
     def post(self, request, model, **kwargs):

+ 15 - 0
netbox/templates/core/object_jobs.html

@@ -0,0 +1,15 @@
+{% extends base_template %}
+{% load render_table from django_tables2 %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <div class="card-body table-responsive">
+          {% render_table table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

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

@@ -2,24 +2,24 @@
 {% load helpers %}
 
 <p>
-  {% if result.started %}
-    Started: <strong>{{ result.started|annotated_date }}</strong>
-  {% elif result.scheduled %}
-    Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> ({{ result.scheduled|naturaltime }})
+  {% if job.started %}
+    Started: <strong>{{ job.started|annotated_date }}</strong>
+  {% elif job.scheduled %}
+    Scheduled for: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
   {% else %}
-    Created: <strong>{{ result.created|annotated_date }}</strong>
+    Created: <strong>{{ job.created|annotated_date }}</strong>
   {% endif %}
-  {% if result.completed %}
-    Duration: <strong>{{ result.duration }}</strong>
+  {% if job.completed %}
+    Duration: <strong>{{ job.duration }}</strong>
   {% endif %}
-  <span id="pending-result-label">{% badge result.get_status_display result.get_status_color %}</span>
+  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
 </p>
-{% if result.completed %}
+{% if job.completed %}
   <div class="card">
     <h5 class="card-header">Report Methods</h5>
     <div class="card-body">
       <table class="table table-hover">
-        {% for method, data in result.data.items %}
+        {% for method, data in job.data.items %}
           <tr>
             <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
             <td class="text-end report-stats">
@@ -46,7 +46,7 @@
           </tr>
         </thead>
         <tbody>
-          {% for method, data in result.data.items %}
+          {% for method, data in job.data.items %}
             <tr>
               <th colspan="4" style="font-family: monospace">
                 <a name="{{ method }}"></a>{{ method }}
@@ -75,6 +75,6 @@
       </table>
     </div>
   </div>
-{% elif result.started %}
+{% elif job.started %}
   {% include 'extras/inc/result_pending.html' %}
 {% endif %}

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

@@ -3,19 +3,19 @@
 {% load log_levels %}
 
 <p>
-  {% if result.started %}
-    Started: <strong>{{ result.started|annotated_date }}</strong>
-  {% elif result.scheduled %}
-    Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> ({{ result.scheduled|naturaltime }})
+  {% if job.started %}
+    Started: <strong>{{ job.started|annotated_date }}</strong>
+  {% elif job.scheduled %}
+    Scheduled for: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
   {% else %}
-    Created: <strong>{{ result.created|annotated_date }}</strong>
+    Created: <strong>{{ job.created|annotated_date }}</strong>
   {% endif %}
-  {% if result.completed %}
-    Duration: <strong>{{ result.duration }}</strong>
+  {% if job.completed %}
+    Duration: <strong>{{ job.duration }}</strong>
   {% endif %}
-  <span id="pending-result-label">{% badge result.get_status_display result.get_status_color %}</span>
+  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
 </p>
-{% if result.completed %}
+{% if job.completed %}
   <div class="card mb-3">
     <h5 class="card-header">Script Log</h5>
     <div class="card-body">
@@ -25,7 +25,7 @@
           <th>Level</th>
           <th>Message</th>
         </tr>
-        {% for log in result.data.log %}
+        {% for log in job.data.log %}
           <tr>
             <td>{{ forloop.counter }}</td>
             <td>{% log_level log.status %}</td>
@@ -47,11 +47,11 @@
     {% endif %}
   </div>
   <h4>Output</h4>
-  {% if result.data.output %}
-    <pre class="block">{{ result.data.output }}</pre>
+  {% if job.data.output %}
+    <pre class="block">{{ job.data.output }}</pre>
   {% else %}
     <p class="text-muted">None</p>
   {% endif %}
-{% elif result.started %}
+{% elif job.started %}
   {% include 'extras/inc/result_pending.html' %}
 {% endif %}

+ 2 - 31
netbox/templates/extras/report.html

@@ -1,36 +1,7 @@
-{% extends 'generic/object.html' %}
+{% extends 'extras/report/base.html' %}
 {% load helpers %}
 {% load form_helpers %}
 
-{% block title %}{{ report.name }}{% endblock %}
-
-{% block object_identifier %}
-  {{ report.full_name }}
-{% endblock %}
-
-{% 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{{ module.pk }}">{{ report.module|bettertitle }}</a></li>
-{% endblock breadcrumbs %}
-
-{% block subtitle %}
-  {% if report.description %}
-    <div class="object-subtitle">
-      <div class="text-muted">{{ report.description|markdown }}</div>
-    </div>
-  {% endif %}
-{% endblock subtitle %}
-
-{% block controls %}{% endblock %}
-
-{% block tabs %}
-  <ul class="nav nav-tabs px-3">
-    <li class="nav-item" role="presentation">
-      <a href="#report" role="tab" data-bs-toggle="tab" class="nav-link active">Report</a>
-    </li>
-  </ul>
-{% endblock tabs %}
-
 {% block content %}
   <div role="tabpanel" class="tab-pane active" id="report">
     {% if perms.extras.run_report %}
@@ -55,7 +26,7 @@
     <div class="row">
       <div class="col col-md-12">
         {% if report.result %}
-          Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
+          Last run: <a href="{% url 'extras:report_result' job_pk=report.result.pk %}">
             <strong>{{ report.result.created|annotated_date }}</strong>
           </a>
         {% endif %}

+ 35 - 0
netbox/templates/extras/report/base.html

@@ -0,0 +1,35 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block title %}{{ report.name }}{% endblock %}
+
+{% block object_identifier %}
+  {{ report.full_name }}
+{% endblock %}
+
+{% 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{{ module.pk }}">{{ report.module|bettertitle }}</a></li>
+{% endblock breadcrumbs %}
+
+{% block subtitle %}
+  {% if report.description %}
+    <div class="object-subtitle">
+      <div class="text-muted">{{ report.description|markdown }}</div>
+    </div>
+  {% endif %}
+{% endblock subtitle %}
+
+{% block controls %}{% endblock %}
+
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <a class="nav-link{% if not tab %} active{% endif %}" href="{% url 'extras:report' module=report.module name=report.class_name %}">Report</a>
+    </li>
+    <li class="nav-item" role="presentation">
+      <a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:report_jobs' module=report.module name=report.class_name %}">Jobs</a>
+    </li>
+  </ul>
+{% endblock tabs %}

+ 15 - 0
netbox/templates/extras/report/jobs.html

@@ -0,0 +1,15 @@
+{% extends 'extras/report/base.html' %}
+{% load render_table from django_tables2 %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <div class="card-body table-responsive">
+          {% render_table table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 2 - 2
netbox/templates/extras/report_list.html

@@ -50,7 +50,7 @@
             </thead>
             <tbody>
               {% for report_name, report in module.reports.items %}
-                {% with last_result=job_results|get_key:report.full_name %}
+                {% with last_result=jobs|get_key:report.full_name %}
                   <tr>
                     <td>
                       <a href="{% url 'extras:report' module=module.path name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
@@ -58,7 +58,7 @@
                     <td>{{ report.description|markdown|placeholder }}</td>
                     {% if last_result %}
                       <td>
-                        <a href="{% url 'extras:report_result' job_result_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                        <a href="{% url 'extras:report_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
                       </td>
                       <td>
                         {% badge last_result.get_status_display last_result.get_status_color %}

+ 3 - 3
netbox/templates/extras/report_result.html

@@ -4,7 +4,7 @@
 
 {% block content-wrapper %}
   <div class="row p-3">
-    <div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:report_result' job_result_pk=result.pk %}" hx-trigger="every 5s"{% endif %}>
+    <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="every 5s"{% endif %}>
       {% include 'extras/htmx/report_result.html' %}
     </div>
   </div>
@@ -13,8 +13,8 @@
 {% block controls %}
   <div class="controls">
     <div class="control-group">
-      {% if request.user|can_delete:result %}
-        {% delete_button result %}
+      {% if request.user|can_delete:job %}
+        {% delete_button job %}
       {% endif %}
     </div>
   </div>

+ 41 - 77
netbox/templates/extras/script.html

@@ -1,91 +1,55 @@
-{% extends 'generic/object.html' %}
+{% extends 'extras/script/base.html' %}
 {% load helpers %}
 {% load form_helpers %}
 {% load log_levels %}
 
-{% block title %}{{ script }}{% endblock %}
-
-{% block object_identifier %}
-  {{ script.full_name }}
-{% endblock object_identifier %}
-
-{% 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.pk }}">{{ module|bettertitle }}</a></li>
-{% endblock breadcrumbs %}
-
-{% block subtitle %}
-  <div class="object-subtitle">
-    <div class="text-muted">{{ script.Meta.description|markdown }}</div>
-  </div>
-{% endblock subtitle %}
-
-{% block controls %}{% endblock %}
-
-{% block tabs %}
-  <ul class="nav nav-tabs px-3">
-    <li class="nav-item" role="presentation">
-      <a href="#run" role="tab" data-bs-toggle="tab" class="nav-link active">Run</a>
-    </li>
-    <li class="nav-item" role="presentation">
-      <a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
-    </li>
-  </ul>
-{% endblock tabs %}
-
 {% block content %}
-  <div role="tabpanel" class="tab-pane active" id="run">
-    <div class="row">
-      <div class="col">
-        {% if not perms.extras.run_script %}
-          <div class="alert alert-warning">
-            <i class="mdi mdi-alert"></i>
-            You do not have permission to run scripts.
-          </div>
-        {% endif %}
-        <form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
-          {% csrf_token %}
-          <div class="field-group my-4">
-            {% if form.requires_input %}
-              {% if script.Meta.fieldsets %}
-                {# Render grouped fields according to declared fieldsets #}
-                {% for group, fields in script.Meta.fieldsets %}
-                  <div class="field-group mb-5">
-                    <div class="row mb-2">
-                      <h5 class="offset-sm-3">{{ group }}</h5>
-                    </div>
-                    {% for name in fields %}
-                      {% with field=form|getfield:name %}
-                        {% render_field field %}
-                      {% endwith %}
-                    {% endfor %}
+  <div class="row">
+    <div class="col">
+      {% if not perms.extras.run_script %}
+        <div class="alert alert-warning">
+          <i class="mdi mdi-alert"></i>
+          You do not have permission to run scripts.
+        </div>
+      {% endif %}
+      <form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
+        {% csrf_token %}
+        <div class="field-group my-4">
+          {% if form.requires_input %}
+            {% if script.Meta.fieldsets %}
+              {# Render grouped fields according to declared fieldsets #}
+              {% for group, fields in script.Meta.fieldsets %}
+                <div class="field-group mb-5">
+                  <div class="row mb-2">
+                    <h5 class="offset-sm-3">{{ group }}</h5>
                   </div>
-                {% endfor %}
-              {% else %}
-                {# Render all fields as a single group #}
-                <div class="row mb-2">
-                  <h5 class="offset-sm-3">Script Data</h5>
+                  {% for name in fields %}
+                    {% with field=form|getfield:name %}
+                      {% render_field field %}
+                    {% endwith %}
+                  {% endfor %}
                 </div>
-                {% render_form form %}
-              {% endif %}
+              {% endfor %}
             {% else %}
-              <div class="alert alert-info">
-                <i class="mdi mdi-information"></i>
-                This script does not require any input to run.
+              {# Render all fields as a single group #}
+              <div class="row mb-2">
+                <h5 class="offset-sm-3">Script Data</h5>
               </div>
               {% render_form form %}
             {% endif %}
-          </div>
-          <div class="float-end">
-            <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
-            <button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
-          </div>
-        </form>
-      </div>
+          {% else %}
+            <div class="alert alert-info">
+              <i class="mdi mdi-information"></i>
+              This script does not require any input to run.
+            </div>
+            {% render_form form %}
+          {% endif %}
+        </div>
+        <div class="float-end">
+          <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
+          <button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
+        </div>
+      </form>
     </div>
   </div>
-  <div role="tabpanel" class="tab-pane" id="source">
-    <code class="h6 my-3 d-block">{{ script.filename }}</code>
-    <pre class="block">{{ script.source }}</pre>
-  </div>
 {% endblock content %}

+ 37 - 0
netbox/templates/extras/script/base.html

@@ -0,0 +1,37 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load form_helpers %}
+{% load log_levels %}
+
+{% block title %}{{ script }}{% endblock %}
+
+{% block object_identifier %}
+  {{ script.full_name }}
+{% endblock object_identifier %}
+
+{% 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.pk }}">{{ module|bettertitle }}</a></li>
+{% endblock breadcrumbs %}
+
+{% block subtitle %}
+  <div class="object-subtitle">
+    <div class="text-muted">{{ script.Meta.description|markdown }}</div>
+  </div>
+{% endblock subtitle %}
+
+{% block controls %}{% endblock %}
+
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <a class="nav-link{% if not tab %} active{% endif %}" href="{% url 'extras:script' module=script.module name=script.class_name %}">Script</a>
+    </li>
+    <li class="nav-item" role="presentation">
+      <a class="nav-link{% if tab == 'source' %} active{% endif %}" href="{% url 'extras:script_source' module=script.module name=script.class_name %}">Source</a>
+    </li>
+    <li class="nav-item" role="presentation">
+      <a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}">Jobs</a>
+    </li>
+  </ul>
+{% endblock tabs %}

+ 15 - 0
netbox/templates/extras/script/jobs.html

@@ -0,0 +1,15 @@
+{% extends 'extras/script/base.html' %}
+{% load render_table from django_tables2 %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <div class="card-body table-responsive">
+          {% render_table table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 6 - 0
netbox/templates/extras/script/source.html

@@ -0,0 +1,6 @@
+{% extends 'extras/script/base.html' %}
+
+{% block content %}
+  <code class="h6 my-3 d-block">{{ script.filename }}</code>
+  <pre class="block">{{ script.source }}</pre>
+{% endblock %}

+ 2 - 2
netbox/templates/extras/script_list.html

@@ -48,7 +48,7 @@
             </thead>
             <tbody>
               {% for script_name, script_class in module.scripts.items %}
-                {% with last_result=job_results|get_key:script_class.full_name %}
+                {% with last_result=jobs|get_key:script_class.full_name %}
                   <tr>
                     <td>
                       <a href="{% url 'extras:script' module=module.path name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
@@ -58,7 +58,7 @@
                     </td>
                     {% if last_result %}
                       <td>
-                        <a href="{% url 'extras:script_result' job_result_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                        <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
                       </td>
                       <td class="text-end">
                         {% badge last_result.get_status_display last_result.get_status_color %}

+ 5 - 5
netbox/templates/extras/script_result.html

@@ -16,8 +16,8 @@
         <ol class="breadcrumb">
           <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
           <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
-          <li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
-          <li class="breadcrumb-item">{{ result.created|annotated_date }}</li>
+          <li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=script.class_name %}">{{ script }}</a></li>
+          <li class="breadcrumb-item">{{ job.created|annotated_date }}</li>
         </ol>
       </nav>
     </div>
@@ -28,8 +28,8 @@
 {% block controls %}
   <div class="controls">
     <div class="control-group">
-      {% if request.user|can_delete:result %}
-        {% delete_button result %}
+      {% if request.user|can_delete:job %}
+        {% delete_button job %}
       {% endif %}
     </div>
   </div>
@@ -47,7 +47,7 @@
   <div class="tab-content mb-3">
     <div role="tabpanel" class="tab-pane active" id="log">
       <div class="row">
-        <div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:script_result' job_result_pk=result.pk %}" hx-trigger="every 5s"{% endif %}>
+        <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="every 5s"{% endif %}>
           {% include 'extras/htmx/script_result.html' %}
         </div>
       </div>