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

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:
     class Meta:
         model = Job
         model = Job
         fields = [
         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:
     class Meta:
         model = Job
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 5 - 6
netbox/core/jobs.py

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

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

@@ -5,7 +5,7 @@ from fnmatch import fnmatchcase
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
 from django.conf import settings
 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.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.db import models
 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 django.utils.translation import gettext as _
 
 
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
+from netbox.models.features import JobsMixin
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.files import sha256_hash
 from utilities.files import sha256_hash
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -31,7 +32,7 @@ __all__ = (
 logger = logging.getLogger('netbox.core.data')
 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.
     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)
         DataSource.objects.filter(pk=self.pk).update(status=self.status)
 
 
         # Enqueue a sync job
         # Enqueue a sync job
-        job_result = Job.enqueue_job(
+        return Job.enqueue(
             import_string('core.jobs.sync_datasource'),
             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):
     def get_backend(self):
         backend_cls = registry['data_backends'].get(self.type)
         backend_cls = registry['data_backends'].get(self.type)
         backend_params = self.parameters or {}
         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.core.validators import MinValueValidator
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
-from django.urls.exceptions import NoReverseMatch
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
@@ -96,21 +95,12 @@ class Job(models.Model):
     def __str__(self):
     def __str__(self):
         return str(self.job_id)
         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):
     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):
     def get_status_color(self):
         return JobStatusChoices.colors.get(self.status)
         return JobStatusChoices.colors.get(self.status)
@@ -130,6 +120,16 @@ class Job(models.Model):
 
 
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"
         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):
     def start(self):
         """
         """
         Record the job's start time and update its status to "running."
         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)
         self.trigger_webhooks(event=EVENT_JOB_END)
 
 
     @classmethod
     @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
         Create a Job instance and enqueue a job using the given callable
 
 
         Args:
         Args:
             func: The callable object to be enqueued for execution
             func: The callable object to be enqueued for execution
+            instance: The NetBox object to which this job pertains
             name: Name for the job (optional)
             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
             schedule_at: Schedule the job to be executed at the passed date and time
             interval: Recurrence interval (in minutes)
             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)
         queue = django_rq.get_queue(rq_queue_name)
         status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
         status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
         job = Job.objects.create(
         job = Job.objects.create(
+            object_type=object_type,
+            object_id=instance.pk,
             name=name,
             name=name,
             status=status,
             status=status,
-            object_type=obj_type,
             scheduled=schedule_at,
             scheduled=schedule_at,
             interval=interval,
             interval=interval,
             user=user,
             user=user,
@@ -188,9 +190,9 @@ class Job(models.Model):
         )
         )
 
 
         if schedule_at:
         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:
         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
         return job
 
 

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

@@ -6,12 +6,18 @@ from ..models import Job
 
 
 
 
 class JobTable(NetBoxTable):
 class JobTable(NetBoxTable):
+    id = tables.Column(
+        linkify=True
+    )
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
     object_type = columns.ContentTypeColumn(
     object_type = columns.ContentTypeColumn(
         verbose_name=_('Type')
         verbose_name=_('Type')
     )
     )
+    object = tables.Column(
+        linkify=True
+    )
     status = columns.ChoiceFieldColumn()
     status = columns.ChoiceFieldColumn()
     created = columns.DateTimeColumn()
     created = columns.DateTimeColumn()
     scheduled = columns.DateTimeColumn()
     scheduled = columns.DateTimeColumn()
@@ -25,10 +31,9 @@ class JobTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Job
         model = Job
         fields = (
         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 = (
         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):
     def post(self, request, pk):
         datasource = get_object_or_404(self.queryset, pk=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())
         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.contrib.contenttypes.models import ContentType
 from django.http import Http404
 from django.http import Http404
+from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.decorators import action
@@ -16,8 +17,8 @@ from core.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
 from extras import filtersets
 from extras import filtersets
 from extras.models import *
 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.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
 from netbox.api.features import SyncedDataMixin
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
@@ -170,19 +171,17 @@ class ReportViewSet(ViewSet):
     exclude_from_schema = True
     exclude_from_schema = True
     lookup_value_regex = '[^/]+'  # Allow dots
     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
             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:
         if report is None:
             raise Http404
             raise Http404
 
 
-        return report
+        return module, report
 
 
     def list(self, request):
     def list(self, request):
         """
         """
@@ -215,13 +214,13 @@ class ReportViewSet(ViewSet):
         """
         """
         Retrieve a single Report identified as "<module>.<report>".
         Retrieve a single Report identified as "<module>.<report>".
         """
         """
+        module, report = self._get_report(pk)
 
 
         # Retrieve the Report and Job, if any.
         # 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(
         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
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
 
 
@@ -245,14 +244,14 @@ class ReportViewSet(ViewSet):
             raise RQWorkerNotRunningException()
             raise RQWorkerNotRunningException()
 
 
         # Retrieve and run the Report. This will create a new Job.
         # 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)
         input_serializer = serializers.ReportInputSerializer(data=request.data)
 
 
         if input_serializer.is_valid():
         if input_serializer.is_valid():
-            report.result = Job.enqueue_job(
+            report.result = Job.enqueue(
                 run_report,
                 run_report,
-                name=report.full_name,
-                obj_type=ContentType.objects.get_for_model(Report),
+                instance=module,
+                name=report.class_name,
                 user=request.user,
                 user=request.user,
                 job_timeout=report.job_timeout,
                 job_timeout=report.job_timeout,
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
@@ -275,11 +274,16 @@ class ScriptViewSet(ViewSet):
     lookup_value_regex = '[^/]+'  # Allow dots
     lookup_value_regex = '[^/]+'  # Allow dots
 
 
     def _get_script(self, pk):
     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:
         if script is None:
             raise Http404
             raise Http404
-        return script
+
+        return module, script
 
 
     def list(self, request):
     def list(self, request):
 
 
@@ -305,11 +309,11 @@ class ScriptViewSet(ViewSet):
         return Response(serializer.data)
         return Response(serializer.data)
 
 
     def retrieve(self, request, pk):
     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(
         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
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
@@ -324,7 +328,7 @@ class ScriptViewSet(ViewSet):
         if not request.user.has_perm('extras.run_script'):
         if not request.user.has_perm('extras.run_script'):
             raise PermissionDenied("This user does not have permission to run scripts.")
             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)
         input_serializer = serializers.ScriptInputSerializer(data=request.data)
 
 
         # Check that at least one RQ worker is running
         # Check that at least one RQ worker is running
@@ -332,10 +336,10 @@ class ScriptViewSet(ViewSet):
             raise RQWorkerNotRunningException()
             raise RQWorkerNotRunningException()
 
 
         if input_serializer.is_valid():
         if input_serializer.is_valid():
-            script.result = Job.enqueue_job(
+            script.result = Job.enqueue(
                 run_script,
                 run_script,
-                name=script.full_name,
-                obj_type=ContentType.objects.get_for_model(Script),
+                instance=module,
+                name=script.class_name,
                 user=request.user,
                 user=request.user,
                 data=input_serializer.data['data'],
                 data=input_serializer.data['data'],
                 request=copy_safe_request(request),
                 request=copy_safe_request(request),

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

@@ -1,6 +1,5 @@
 import time
 import time
 
 
-from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 from django.utils import timezone
 
 
@@ -27,12 +26,10 @@ class Command(BaseCommand):
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                         "[{:%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,
                         run_report,
-                        report.full_name,
-                        report_content_type,
-                        None,
+                        instance=module,
+                        name=report.class_name,
                         job_timeout=report.job_timeout
                         job_timeout=report.job_timeout
                     )
                     )
 
 

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

@@ -5,7 +5,6 @@ import traceback
 import uuid
 import uuid
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
-from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand, CommandError
 from django.core.management.base import BaseCommand, CommandError
 from django.db import transaction
 from django.db import transaction
 
 
@@ -13,7 +12,7 @@ from core.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
 from extras.api.serializers import ScriptOutputSerializer
 from extras.api.serializers import ScriptOutputSerializer
 from extras.context_managers import change_logging
 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 extras.signals import clear_webhooks
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
 from utilities.utils import NetBoxFakeRequest
 from utilities.utils import NetBoxFakeRequest
@@ -49,8 +48,8 @@ class Command(BaseCommand):
                 except AbortTransaction:
                 except AbortTransaction:
                     script.log_info("Database changes have been reverted automatically.")
                     script.log_info("Database changes have been reverted automatically.")
                     clear_webhooks.send(request)
                     clear_webhooks.send(request)
-                job_result.data = ScriptOutputSerializer(script).data
-                job_result.terminate()
+                job.data = ScriptOutputSerializer(script).data
+                job.terminate()
             except Exception as e:
             except Exception as e:
                 stacktrace = traceback.format_exc()
                 stacktrace = traceback.format_exc()
                 script.log_failure(
                 script.log_failure(
@@ -59,10 +58,10 @@ class Command(BaseCommand):
                 script.log_info("Database changes have been reverted due to error.")
                 script.log_info("Database changes have been reverted due to error.")
                 logger.error(f"Exception raised during script execution: {e}")
                 logger.error(f"Exception raised during script execution: {e}")
                 clear_webhooks.send(request)
                 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
         # Params
         script = options['script']
         script = options['script']
@@ -73,7 +72,8 @@ class Command(BaseCommand):
         except TypeError:
         except TypeError:
             data = {}
             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
         # Take user from command line if provided and exists, other
         if options['user']:
         if options['user']:
@@ -90,7 +90,7 @@ class Command(BaseCommand):
         stdouthandler.setLevel(logging.DEBUG)
         stdouthandler.setLevel(logging.DEBUG)
         stdouthandler.setFormatter(formatter)
         stdouthandler.setFormatter(formatter)
 
 
-        logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
+        logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
         logger.addHandler(stdouthandler)
         logger.addHandler(stdouthandler)
 
 
         try:
         try:
@@ -105,17 +105,14 @@ class Command(BaseCommand):
         except KeyError:
         except KeyError:
             raise CommandError(f"Invalid log level: {loglevel}")
             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)
         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],
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             job_id=uuid.uuid4()
             job_id=uuid.uuid4()
         )
         )
@@ -127,12 +124,12 @@ class Command(BaseCommand):
             'FILES': {},
             'FILES': {},
             'user': user,
             'user': user,
             'path': '',
             'path': '',
-            'id': job_result.job_id
+            'id': job.job_id
         })
         })
 
 
         if form.is_valid():
         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})")
             logger.info(f"Running script (commit={commit})")
             script.request = request
             script.request = request
@@ -146,5 +143,5 @@ class Command(BaseCommand):
             for field, errors in form.errors.get_json_data().items():
             for field, errors in form.errors.get_json_data().items():
                 for error in errors:
                 for error in errors:
                     logger.error(f'\t{field}: {error.get("message")}')
                     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
 import inspect
 from functools import cached_property
 from functools import cached_property
 
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 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.
     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)
         return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
 
 
 
 
-class ReportModule(PythonModuleMixin, ManagedFile):
+class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
     """
     """
     Proxy model for report module files.
     Proxy model for report module files.
     """
     """

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

@@ -1,6 +1,7 @@
 import inspect
 import inspect
 from functools import cached_property
 from functools import cached_property
 
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 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.
     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)
         return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
 
 
 
 
-class ScriptModule(PythonModuleMixin, ManagedFile):
+class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
     """
     """
     Proxy model for script module files.
     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 .choices import LogLevelChoices
 from .models import ReportModule
 from .models import ReportModule
 
 
+__all__ = (
+    'Report',
+    'get_module_and_report',
+    'run_report',
+)
+
 logger = logging.getLogger(__name__)
 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')
     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')
 @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
     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.
     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:
     try:
-        job_result.start()
-        report.run(job_result)
+        report.run(job)
     except Exception:
     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:
     finally:
         # Schedule the next job if an interval has been set
         # 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,
                 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,
                 job_timeout=report.job_timeout,
                 schedule_at=new_scheduled_time,
                 schedule_at=new_scheduled_time,
-                interval=job_result.interval
+                interval=job.interval
             )
             )
 
 
 
 
@@ -186,13 +190,13 @@ class Report(object):
     # Run methods
     # 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.
         Run the report and save its results. Each test method will be executed in order.
         """
         """
         self.logger.info(f"Running report")
         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
         # Perform any post-run tasks
         self.pre_run()
         self.pre_run()
@@ -204,17 +208,17 @@ class Report(object):
                 test_method()
                 test_method()
             if self.failed:
             if self.failed:
                 self.logger.warning("Report failed")
                 self.logger.warning("Report failed")
-                job_result.status = JobStatusChoices.STATUS_FAILED
+                job.status = JobStatusChoices.STATUS_FAILED
             else:
             else:
                 self.logger.info("Report completed successfully")
                 self.logger.info("Report completed successfully")
-                job_result.status = JobStatusChoices.STATUS_COMPLETED
+                job.status = JobStatusChoices.STATUS_COMPLETED
         except Exception as e:
         except Exception as e:
             stacktrace = traceback.format_exc()
             stacktrace = traceback.format_exc()
             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
             logger.error(f"Exception raised during report execution: {e}")
             logger.error(f"Exception raised during report execution: {e}")
-            job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
+            job.terminate(status=JobStatusChoices.STATUS_ERRORED)
         finally:
         finally:
-            job_result.terminate()
+            job.terminate()
 
 
         # Perform any post-run tasks
         # Perform any post-run tasks
         self.post_run()
         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 .context_managers import change_logging
 from .forms import ScriptForm
 from .forms import ScriptForm
 
 
-__all__ = [
+__all__ = (
     'BaseScript',
     'BaseScript',
     'BooleanVar',
     'BooleanVar',
     'ChoiceVar',
     'ChoiceVar',
@@ -40,7 +40,9 @@ __all__ = [
     'Script',
     'Script',
     'StringVar',
     'StringVar',
     'TextVar',
     'TextVar',
-]
+    'get_module_and_script',
+    'run_script',
+)
 
 
 
 
 #
 #
@@ -436,18 +438,23 @@ def is_variable(obj):
     return isinstance(obj, ScriptVariable)
     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
     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.
     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})")
     logger.info(f"Running script (commit={commit})")
 
 
     # Add files to form data
     # Add files to form data
@@ -472,8 +479,8 @@ def run_script(data, request, commit=True, *args, **kwargs):
             except AbortTransaction:
             except AbortTransaction:
                 script.log_info("Database changes have been reverted automatically.")
                 script.log_info("Database changes have been reverted automatically.")
                 clear_webhooks.send(request)
                 clear_webhooks.send(request)
-            job_result.data = ScriptOutputSerializer(script).data
-            job_result.terminate()
+            job.data = ScriptOutputSerializer(script).data
+            job.terminate()
         except Exception as e:
         except Exception as e:
             if type(e) is AbortScript:
             if type(e) is AbortScript:
                 script.log_failure(f"Script aborted with error: {e}")
                 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```")
                 script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
                 logger.error(f"Exception raised during script execution: {e}")
                 logger.error(f"Exception raised during script execution: {e}")
             script.log_info("Database changes have been reverted due to error.")
             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)
             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
     # Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
     # change logging, webhooks, etc.
     # change logging, webhooks, etc.
@@ -498,25 +505,17 @@ def run_script(data, request, commit=True, *args, **kwargs):
         _run_script()
         _run_script()
 
 
     # Schedule the next job if an interval has been set
     # 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,
             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,
             schedule_at=new_scheduled_time,
-            interval=job_result.interval,
+            interval=job.interval,
             job_timeout=script.job_timeout,
             job_timeout=script.job_timeout,
             data=data,
             data=data,
             request=request,
             request=request,
             commit=commit
             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 rest_framework import status
 from rq import Worker
 from rq import Worker
 
 
+from core.choices import ManagedFileRootPathChoices
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
 from extras.api.views import ReportViewSet, ScriptViewSet
 from extras.api.views import ReportViewSet, ScriptViewSet
 from extras.models import *
 from extras.models import *
@@ -524,14 +525,21 @@ class ReportTest(APITestCase):
         def test_foo(self):
         def test_foo(self):
             self.log_success(None, "Report completed")
             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):
     def get_test_report(self, *args):
-        return self.TestReport()
+        return ReportModule.objects.first(), self.TestReport()
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         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):
     def test_get_report(self):
         url = reverse('extras-api:report-detail', kwargs={'pk': None})
         url = reverse('extras-api:report-detail', kwargs={'pk': None})
@@ -569,14 +577,20 @@ class ScriptTest(APITestCase):
 
 
             return 'Script complete'
             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):
     def get_test_script(self, *args):
-        return self.TestScript
+        return ScriptModule.objects.first(), self.TestScript
 
 
     def setUp(self):
     def setUp(self):
-
         super().setUp()
         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
         ScriptViewSet._get_script = self.get_test_script
 
 
     def test_get_script(self):
     def test_get_script(self):

+ 5 - 2
netbox/extras/urls.py

@@ -95,16 +95,19 @@ urlpatterns = [
     # Reports
     # Reports
     path('reports/', views.ReportListView.as_view(), name='report_list'),
     path('reports/', views.ReportListView.as_view(), name='report_list'),
     path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
     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/<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>/', views.ReportView.as_view(), name='report'),
+    path('reports/<path:module>.<str:name>/jobs/', views.ReportJobsView.as_view(), name='report_jobs'),
 
 
     # Scripts
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
     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/<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>/', 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
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_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.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.forms import ManagedFileForm
 from core.forms import ManagedFileForm
 from core.models import Job
 from core.models import Job
+from core.tables import JobTable
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 from netbox.views import generic
 from netbox.views import generic
@@ -22,7 +23,7 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .forms.reports import ReportForm
 from .forms.reports import ReportForm
 from .models import *
 from .models import *
-from .reports import get_report, run_report
+from .reports import run_report
 from .scripts import run_script
 from .scripts import run_script
 
 
 
 
@@ -819,7 +820,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         report_modules = ReportModule.objects.restrict(request.user)
         report_modules = ReportModule.objects.restrict(request.user)
 
 
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        job_results = {
+        jobs = {
             r.name: r
             r.name: r
             for r in Job.objects.filter(
             for r in Job.objects.filter(
                 object_type=report_content_type,
                 object_type=report_content_type,
@@ -830,7 +831,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         return render(request, 'extras/report_list.html', {
         return render(request, 'extras/report_list.html', {
             'model': ReportModule,
             'model': ReportModule,
             'report_modules': report_modules,
             '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')
         module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path=f'{module}.py')
         report = module.reports[name]()
         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(
         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
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
 
 
@@ -876,17 +878,17 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
                 })
                 })
 
 
             # Run the Report. A new Job is created.
             # Run the Report. A new Job is created.
-            job_result = Job.enqueue_job(
+            job = Job.enqueue(
                 run_report,
                 run_report,
-                name=report.full_name,
-                obj_type=ContentType.objects.get_for_model(Report),
+                instance=module,
+                name=report.class_name,
                 user=request.user,
                 user=request.user,
                 schedule_at=form.cleaned_data.get('schedule_at'),
                 schedule_at=form.cleaned_data.get('schedule_at'),
                 interval=form.cleaned_data.get('interval'),
                 interval=form.cleaned_data.get('interval'),
                 job_timeout=report.job_timeout
                 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', {
         return render(request, 'extras/report.html', {
             'module': module,
             '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):
 class ReportResultView(ContentTypePermissionRequiredMixin, View):
     """
     """
     Display a Job pertaining to the execution of a Report.
     Display a Job pertaining to the execution of a Report.
@@ -902,28 +936,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
     def get_required_permission(self):
     def get_required_permission(self):
         return 'extras.view_report'
         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 this is an HTMX request, return only the result HTML
         if is_htmx(request):
         if is_htmx(request):
             response = render(request, 'extras/htmx/report_result.html', {
             response = render(request, 'extras/htmx/report_result.html', {
                 'report': report,
                 'report': report,
-                'result': result,
+                'job': job,
             })
             })
-            if result.completed or not result.started:
+            if job.completed or not job.started:
                 response.status_code = 286
                 response.status_code = 286
             return response
             return response
 
 
         return render(request, 'extras/report_result.html', {
         return render(request, 'extras/report_result.html', {
             'report': report,
             'report': report,
-            'result': result,
+            'job': job,
         })
         })
 
 
 
 
@@ -956,7 +988,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         script_modules = ScriptModule.objects.restrict(request.user)
         script_modules = ScriptModule.objects.restrict(request.user)
 
 
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
-        job_results = {
+        jobs = {
             r.name: r
             r.name: r
             for r in Job.objects.filter(
             for r in Job.objects.filter(
                 object_type=script_content_type,
                 object_type=script_content_type,
@@ -967,7 +999,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         return render(request, 'extras/script_list.html', {
         return render(request, 'extras/script_list.html', {
             'model': ScriptModule,
             'model': ScriptModule,
             'script_modules': script_modules,
             '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))
         form = script.as_form(initial=normalize_querydict(request.GET))
 
 
         # Look for a pending Job (use the latest one by creation timestamp)
         # 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(
         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(
         ).exclude(
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
@@ -1008,10 +1042,10 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
             messages.error(request, "Unable to run script: RQ worker process not running.")
             messages.error(request, "Unable to run script: RQ worker process not running.")
 
 
         elif form.is_valid():
         elif form.is_valid():
-            job_result = Job.enqueue_job(
+            job = Job.enqueue(
                 run_script,
                 run_script,
-                name=script.full_name,
-                obj_type=ContentType.objects.get_for_model(Script),
+                instance=module,
+                name=script.class_name,
                 user=request.user,
                 user=request.user,
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 interval=form.cleaned_data.pop('_interval'),
                 interval=form.cleaned_data.pop('_interval'),
@@ -1021,7 +1055,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
                 commit=form.cleaned_data.pop('_commit')
                 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', {
         return render(request, 'extras/script.html', {
             'module': module,
             '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):
 class ScriptResultView(ContentTypePermissionRequiredMixin, View):
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return 'extras.view_script'
         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 this is an HTMX request, return only the result HTML
         if is_htmx(request):
         if is_htmx(request):
             response = render(request, 'extras/htmx/script_result.html', {
             response = render(request, 'extras/htmx/script_result.html', {
                 'script': script,
                 'script': script,
-                'result': result,
+                'job': job,
             })
             })
-            if result.completed or not result.started:
+            if job.completed or not job.started:
                 response.status_code = 286
                 response.status_code = 286
             return response
             return response
 
 
         return render(request, 'extras/script_result.html', {
         return render(request, 'extras/script_result.html', {
             'script': script,
             '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.
     Enables support for job results.
     """
     """
+    jobs = GenericRelation(
+        to='core.Job',
+        content_type_field='object_type',
+        object_id_field='object_id'
+    )
+
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
@@ -455,6 +461,12 @@ def _register_features(sender, **kwargs):
             'changelog',
             'changelog',
             kwargs={'model': sender}
             kwargs={'model': sender}
         )('netbox.views.generic.ObjectChangeLogView')
         )('netbox.views.generic.ObjectChangeLogView')
+    if issubclass(sender, JobsMixin):
+        register_model_view(
+            sender,
+            'jobs',
+            kwargs={'model': sender}
+        )('netbox.views.generic.ObjectJobsView')
     if issubclass(sender, SyncedDataMixin):
     if issubclass(sender, SyncedDataMixin):
         register_model_view(
         register_model_view(
             sender,
             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.utils.translation import gettext as _
 from django.views.generic import View
 from django.views.generic import View
 
 
+from core.models import Job
+from core.tables import JobTable
 from extras import forms, tables
 from extras import forms, tables
 from extras.models import *
 from extras.models import *
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
@@ -15,6 +17,7 @@ from .base import BaseMultiObjectView
 __all__ = (
 __all__ = (
     'BulkSyncDataView',
     'BulkSyncDataView',
     'ObjectChangeLogView',
     'ObjectChangeLogView',
+    'ObjectJobsView',
     'ObjectJournalView',
     'ObjectJournalView',
     'ObjectSyncDataView',
     '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):
 class ObjectSyncDataView(View):
 
 
     def post(self, request, model, **kwargs):
     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 %}
 {% load helpers %}
 
 
 <p>
 <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 %}
   {% else %}
-    Created: <strong>{{ result.created|annotated_date }}</strong>
+    Created: <strong>{{ job.created|annotated_date }}</strong>
   {% endif %}
   {% endif %}
-  {% if result.completed %}
-    Duration: <strong>{{ result.duration }}</strong>
+  {% if job.completed %}
+    Duration: <strong>{{ job.duration }}</strong>
   {% endif %}
   {% 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>
 </p>
-{% if result.completed %}
+{% if job.completed %}
   <div class="card">
   <div class="card">
     <h5 class="card-header">Report Methods</h5>
     <h5 class="card-header">Report Methods</h5>
     <div class="card-body">
     <div class="card-body">
       <table class="table table-hover">
       <table class="table table-hover">
-        {% for method, data in result.data.items %}
+        {% for method, data in job.data.items %}
           <tr>
           <tr>
             <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
             <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
             <td class="text-end report-stats">
             <td class="text-end report-stats">
@@ -46,7 +46,7 @@
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          {% for method, data in result.data.items %}
+          {% for method, data in job.data.items %}
             <tr>
             <tr>
               <th colspan="4" style="font-family: monospace">
               <th colspan="4" style="font-family: monospace">
                 <a name="{{ method }}"></a>{{ method }}
                 <a name="{{ method }}"></a>{{ method }}
@@ -75,6 +75,6 @@
       </table>
       </table>
     </div>
     </div>
   </div>
   </div>
-{% elif result.started %}
+{% elif job.started %}
   {% include 'extras/inc/result_pending.html' %}
   {% include 'extras/inc/result_pending.html' %}
 {% endif %}
 {% endif %}

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

@@ -3,19 +3,19 @@
 {% load log_levels %}
 {% load log_levels %}
 
 
 <p>
 <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 %}
   {% else %}
-    Created: <strong>{{ result.created|annotated_date }}</strong>
+    Created: <strong>{{ job.created|annotated_date }}</strong>
   {% endif %}
   {% endif %}
-  {% if result.completed %}
-    Duration: <strong>{{ result.duration }}</strong>
+  {% if job.completed %}
+    Duration: <strong>{{ job.duration }}</strong>
   {% endif %}
   {% 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>
 </p>
-{% if result.completed %}
+{% if job.completed %}
   <div class="card mb-3">
   <div class="card mb-3">
     <h5 class="card-header">Script Log</h5>
     <h5 class="card-header">Script Log</h5>
     <div class="card-body">
     <div class="card-body">
@@ -25,7 +25,7 @@
           <th>Level</th>
           <th>Level</th>
           <th>Message</th>
           <th>Message</th>
         </tr>
         </tr>
-        {% for log in result.data.log %}
+        {% for log in job.data.log %}
           <tr>
           <tr>
             <td>{{ forloop.counter }}</td>
             <td>{{ forloop.counter }}</td>
             <td>{% log_level log.status %}</td>
             <td>{% log_level log.status %}</td>
@@ -47,11 +47,11 @@
     {% endif %}
     {% endif %}
   </div>
   </div>
   <h4>Output</h4>
   <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 %}
   {% else %}
     <p class="text-muted">None</p>
     <p class="text-muted">None</p>
   {% endif %}
   {% endif %}
-{% elif result.started %}
+{% elif job.started %}
   {% include 'extras/inc/result_pending.html' %}
   {% include 'extras/inc/result_pending.html' %}
 {% endif %}
 {% endif %}

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

@@ -1,36 +1,7 @@
-{% extends 'generic/object.html' %}
+{% extends 'extras/report/base.html' %}
 {% load helpers %}
 {% load helpers %}
 {% load form_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 %}
 {% block content %}
   <div role="tabpanel" class="tab-pane active" id="report">
   <div role="tabpanel" class="tab-pane active" id="report">
     {% if perms.extras.run_report %}
     {% if perms.extras.run_report %}
@@ -55,7 +26,7 @@
     <div class="row">
     <div class="row">
       <div class="col col-md-12">
       <div class="col col-md-12">
         {% if report.result %}
         {% 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>
             <strong>{{ report.result.created|annotated_date }}</strong>
           </a>
           </a>
         {% endif %}
         {% 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>
             </thead>
             <tbody>
             <tbody>
               {% for report_name, report in module.reports.items %}
               {% 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>
                   <tr>
                     <td>
                     <td>
                       <a href="{% url 'extras:report' module=module.path name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
                       <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>
                     <td>{{ report.description|markdown|placeholder }}</td>
                     {% if last_result %}
                     {% if last_result %}
                       <td>
                       <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>
                       <td>
                       <td>
                         {% badge last_result.get_status_display last_result.get_status_color %}
                         {% 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 %}
 {% block content-wrapper %}
   <div class="row p-3">
   <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' %}
       {% include 'extras/htmx/report_result.html' %}
     </div>
     </div>
   </div>
   </div>
@@ -13,8 +13,8 @@
 {% block controls %}
 {% block controls %}
   <div class="controls">
   <div class="controls">
     <div class="control-group">
     <div class="control-group">
-      {% if request.user|can_delete:result %}
-        {% delete_button result %}
+      {% if request.user|can_delete:job %}
+        {% delete_button job %}
       {% endif %}
       {% endif %}
     </div>
     </div>
   </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 helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 {% load log_levels %}
 {% 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 %}
 {% 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>
                   </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>
                 </div>
-                {% render_form form %}
-              {% endif %}
+              {% endfor %}
             {% else %}
             {% 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>
               </div>
               {% render_form form %}
               {% render_form form %}
             {% endif %}
             {% 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>
   </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 %}
 {% 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>
             </thead>
             <tbody>
             <tbody>
               {% for script_name, script_class in module.scripts.items %}
               {% 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>
                   <tr>
                     <td>
                     <td>
                       <a href="{% url 'extras:script' module=module.path name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
                       <a href="{% url 'extras:script' module=module.path name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
@@ -58,7 +58,7 @@
                     </td>
                     </td>
                     {% if last_result %}
                     {% if last_result %}
                       <td>
                       <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>
                       <td class="text-end">
                       <td class="text-end">
                         {% badge last_result.get_status_display last_result.get_status_color %}
                         {% 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">
         <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' %}">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_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>
         </ol>
       </nav>
       </nav>
     </div>
     </div>
@@ -28,8 +28,8 @@
 {% block controls %}
 {% block controls %}
   <div class="controls">
   <div class="controls">
     <div class="control-group">
     <div class="control-group">
-      {% if request.user|can_delete:result %}
-        {% delete_button result %}
+      {% if request.user|can_delete:job %}
+        {% delete_button job %}
       {% endif %}
       {% endif %}
     </div>
     </div>
   </div>
   </div>
@@ -47,7 +47,7 @@
   <div class="tab-content mb-3">
   <div class="tab-content mb-3">
     <div role="tabpanel" class="tab-pane active" id="log">
     <div role="tabpanel" class="tab-pane active" id="log">
       <div class="row">
       <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' %}
           {% include 'extras/htmx/script_result.html' %}
         </div>
         </div>
       </div>
       </div>