فهرست منبع

Merge pull request #4799 from netbox-community/2006-scripts-reports-background

2006 scripts reports background
Jeremy Stretch 5 سال پیش
والد
کامیت
6d0281adc8

+ 6 - 15
netbox/extras/admin.py

@@ -2,7 +2,7 @@ from django import forms
 from django.contrib import admin
 
 from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook
+from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook
 from .reports import get_report
 
 
@@ -228,27 +228,18 @@ class ExportTemplateAdmin(admin.ModelAdmin):
 # Reports
 #
 
-@admin.register(ReportResult)
-class ReportResultAdmin(admin.ModelAdmin):
+@admin.register(JobResult)
+class JobResultAdmin(admin.ModelAdmin):
     list_display = [
-        'report', 'active', 'created', 'user', 'passing',
+        'obj_type', 'name', 'created', 'completed', 'user', 'status',
     ]
     fields = [
-        'report', 'user', 'passing', 'data',
+        'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
     ]
     list_filter = [
-        'failed',
+        'status',
     ]
     readonly_fields = fields
 
     def has_add_permission(self, request):
         return False
-
-    def active(self, obj):
-        module, report_name = obj.report.split('.')
-        return True if get_report(module, report_name) else False
-    active.boolean = True
-
-    def passing(self, obj):
-        return not obj.failed
-    passing.boolean = True

+ 11 - 10
netbox/extras/api/nested_serializers.py

@@ -1,13 +1,14 @@
 from rest_framework import serializers
 
-from extras import models
-from utilities.api import WritableNestedSerializer
+from extras import choices, models
+from users.api.nested_serializers import NestedUserSerializer
+from utilities.api import ChoiceField, WritableNestedSerializer
 
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedExportTemplateSerializer',
     'NestedGraphSerializer',
-    'NestedReportResultSerializer',
+    'NestedJobResultSerializer',
     'NestedTagSerializer',
 ]
 
@@ -44,13 +45,13 @@ class NestedTagSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'slug', 'color']
 
 
-class NestedReportResultSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='extras-api:report-detail',
-        lookup_field='report',
-        lookup_url_kwarg='pk'
+class NestedJobResultSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
+    status = ChoiceField(choices=choices.JobResultStatusChoices)
+    user = NestedUserSerializer(
+        read_only=True
     )
 
     class Meta:
-        model = models.ReportResult
-        fields = ['url', 'created', 'user', 'failed']
+        model = models.JobResult
+        fields = ['url', 'created', 'completed', 'user', 'status']

+ 32 - 20
netbox/extras/api/serializers.py

@@ -11,7 +11,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
 from extras.choices import *
 from extras.constants import *
 from extras.models import (
-    ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
+    ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
 )
 from extras.utils import FeatureQuery
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
@@ -233,26 +233,41 @@ class ConfigContextSerializer(ValidatedModelSerializer):
 
 
 #
-# Reports
+# Job Results
 #
 
-class ReportResultSerializer(serializers.ModelSerializer):
+class JobResultSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
+    user = NestedUserSerializer(
+        read_only=True
+    )
+    status = ChoiceField(choices=JobResultStatusChoices, read_only=True)
+    obj_type = ContentTypeField(
+        read_only=True
+    )
 
     class Meta:
-        model = ReportResult
-        fields = ['created', 'user', 'failed', 'data']
+        model = JobResult
+        fields = [
+            'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
+        ]
+
 
+#
+# Reports
+#
 
 class ReportSerializer(serializers.Serializer):
+    id = serializers.CharField(read_only=True, source="full_name")
     module = serializers.CharField(max_length=255)
     name = serializers.CharField(max_length=255)
     description = serializers.CharField(max_length=255, required=False)
     test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
-    result = NestedReportResultSerializer()
+    result = NestedJobResultSerializer()
 
 
 class ReportDetailSerializer(ReportSerializer):
-    result = ReportResultSerializer()
+    result = JobResultSerializer()
 
 
 #
@@ -260,19 +275,12 @@ class ReportDetailSerializer(ReportSerializer):
 #
 
 class ScriptSerializer(serializers.Serializer):
-    id = serializers.SerializerMethodField(read_only=True)
-    name = serializers.SerializerMethodField(read_only=True)
-    description = serializers.SerializerMethodField(read_only=True)
+    id = serializers.CharField(read_only=True, source="full_name")
+    module = serializers.CharField(max_length=255)
+    name = serializers.CharField(read_only=True)
+    description = serializers.CharField(read_only=True)
     vars = serializers.SerializerMethodField(read_only=True)
-
-    def get_id(self, instance):
-        return '{}.{}'.format(instance.__module__, instance.__name__)
-
-    def get_name(self, instance):
-        return getattr(instance.Meta, 'name', instance.__name__)
-
-    def get_description(self, instance):
-        return getattr(instance.Meta, 'description', '')
+    result = NestedJobResultSerializer()
 
     def get_vars(self, instance):
         return {
@@ -280,6 +288,10 @@ class ScriptSerializer(serializers.Serializer):
         }
 
 
+class ScriptDetailSerializer(ScriptSerializer):
+    result = JobResultSerializer()
+
+
 class ScriptInputSerializer(serializers.Serializer):
     data = serializers.JSONField()
     commit = serializers.BooleanField()
@@ -290,7 +302,7 @@ class ScriptLogMessageSerializer(serializers.Serializer):
     message = serializers.SerializerMethodField(read_only=True)
 
     def get_status(self, instance):
-        return LOG_LEVEL_CODES.get(instance[0])
+        return instance[0]
 
     def get_message(self, instance):
         return instance[1]

+ 3 - 0
netbox/extras/api/urls.py

@@ -41,5 +41,8 @@ router.register('scripts', views.ScriptViewSet, basename='script')
 # Change logging
 router.register('object-changes', views.ObjectChangeViewSet)
 
+# Job Results
+router.register('job-results', views.JobResultViewSet)
+
 app_name = 'extras-api'
 urlpatterns = router.urls

+ 85 - 18
netbox/extras/api/views.py

@@ -10,12 +10,14 @@ from rest_framework.response import Response
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 
 from extras import filters
+from extras.choices import JobResultStatusChoices
 from extras.models import (
-    ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
+    ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
 )
-from extras.reports import get_report, get_reports
+from extras.reports import get_report, get_reports, run_report
 from extras.scripts import get_script, get_scripts, run_script
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
+from utilities.utils import copy_safe_request
 from . import serializers
 
 
@@ -165,13 +167,21 @@ class ReportViewSet(ViewSet):
         Compile all reports and their related results (if any). Result data is deferred in the list view.
         """
         report_list = []
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        results = {
+            r.name: r
+            for r in JobResult.objects.filter(
+                obj_type=report_content_type,
+                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            ).defer('data')
+        }
 
         # Iterate through all available Reports.
         for module_name, reports in get_reports():
             for report in reports:
 
-                # Attach the relevant ReportResult (if any) to each Report.
-                report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
+                # Attach the relevant JobResult (if any) to each Report.
+                report.result = results.get(report.full_name, None)
                 report_list.append(report)
 
         serializer = serializers.ReportSerializer(report_list, many=True, context={
@@ -185,29 +195,43 @@ class ReportViewSet(ViewSet):
         Retrieve a single Report identified as "<module>.<report>".
         """
 
-        # Retrieve the Report and ReportResult, if any.
+        # Retrieve the Report and JobResult, if any.
         report = self._retrieve_report(pk)
-        report.result = ReportResult.objects.filter(report=report.full_name).first()
-
-        serializer = serializers.ReportDetailSerializer(report)
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        report.result = JobResult.objects.filter(
+            obj_type=report_content_type,
+            name=report.full_name,
+            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+        ).first()
+
+        serializer = serializers.ReportDetailSerializer(report, context={
+            'request': request
+        })
 
         return Response(serializer.data)
 
     @action(detail=True, methods=['post'])
     def run(self, request, pk):
         """
-        Run a Report and create a new ReportResult, overwriting any previous result for the Report.
+        Run a Report identified as "<module>.<script>" and return the pending JobResult as the result
         """
 
         # Check that the user has permission to run reports.
-        if not request.user.has_perm('extras.add_reportresult'):
+        if not request.user.has_perm('extras.run_script'):
             raise PermissionDenied("This user does not have permission to run reports.")
 
-        # Retrieve and run the Report. This will create a new ReportResult.
+        # Retrieve and run the Report. This will create a new JobResult.
         report = self._retrieve_report(pk)
-        report.run()
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        job_result = JobResult.enqueue_job(
+            run_report,
+            report.full_name,
+            report_content_type,
+            request.user
+        )
+        report.result = job_result
 
-        serializer = serializers.ReportDetailSerializer(report)
+        serializer = serializers.ReportDetailSerializer(report, context={'request': request})
 
         return Response(serializer.data)
 
@@ -231,23 +255,42 @@ class ScriptViewSet(ViewSet):
 
     def list(self, request):
 
+        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+        results = {
+            r.name: r
+            for r in JobResult.objects.filter(
+                obj_type=script_content_type,
+                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            ).defer('data').order_by('created')
+        }
+
         flat_list = []
         for script_list in get_scripts().values():
             flat_list.extend(script_list.values())
 
+        # Attach JobResult objects to each script (if any)
+        for script in flat_list:
+            script.result = results.get(script.full_name, None)
+
         serializer = serializers.ScriptSerializer(flat_list, many=True, context={'request': request})
 
         return Response(serializer.data)
 
     def retrieve(self, request, pk):
         script = self._get_script(pk)
-        serializer = serializers.ScriptSerializer(script, context={'request': request})
+        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+        script.result = JobResult.objects.filter(
+            obj_type=script_content_type,
+            name=script.full_name,
+            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+        ).first()
+        serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
         return Response(serializer.data)
 
     def post(self, request, pk):
         """
-        Run a Script identified as "<module>.<script>".
+        Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
         """
         script = self._get_script(pk)()
         input_serializer = serializers.ScriptInputSerializer(data=request.data)
@@ -255,10 +298,21 @@ class ScriptViewSet(ViewSet):
         if input_serializer.is_valid():
             data = input_serializer.data['data']
             commit = input_serializer.data['commit']
-            script.output, execution_time = run_script(script, data, request, commit)
-            output_serializer = serializers.ScriptOutputSerializer(script)
 
-            return Response(output_serializer.data)
+            script_content_type = ContentType.objects.get(app_label='extras', model='script')
+            job_result = JobResult.enqueue_job(
+                run_script,
+                script.full_name,
+                script_content_type,
+                request.user,
+                data=data,
+                request=copy_safe_request(request),
+                commit=commit
+            )
+            script.result = job_result
+            serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
+
+            return Response(serializer.data)
 
         return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
@@ -274,3 +328,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     queryset = ObjectChange.objects.prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filters.ObjectChangeFilterSet
+
+
+#
+# Job Results
+#
+
+class JobResultViewSet(ReadOnlyModelViewSet):
+    """
+    Retrieve a list of job results
+    """
+    queryset = JobResult.objects.prefetch_related('user')
+    serializer_class = serializers.JobResultSerializer
+    filterset_class = filters.JobResultFilterSet

+ 64 - 0
netbox/extras/choices.py

@@ -120,6 +120,70 @@ class TemplateLanguageChoices(ChoiceSet):
     }
 
 
+#
+# Log Levels for Reports and Scripts
+#
+
+class LogLevelChoices(ChoiceSet):
+
+    LOG_DEFAULT = 'default'
+    LOG_SUCCESS = 'sucess'
+    LOG_INFO = 'info'
+    LOG_WARNING = 'warning'
+    LOG_FAILURE = 'failure'
+
+    CHOICES = (
+        (LOG_DEFAULT, 'Default'),
+        (LOG_SUCCESS, 'Success'),
+        (LOG_INFO, 'Info'),
+        (LOG_WARNING, 'Warning'),
+        (LOG_FAILURE, 'Failure'),
+    )
+
+    CLASS_MAP = (
+        (LOG_DEFAULT, 'default'),
+        (LOG_SUCCESS, 'success'),
+        (LOG_INFO, 'info'),
+        (LOG_WARNING, 'warning'),
+        (LOG_FAILURE, 'danger'),
+    )
+
+    LEGACY_MAP = (
+        (LOG_DEFAULT, 0),
+        (LOG_SUCCESS, 10),
+        (LOG_INFO, 20),
+        (LOG_WARNING, 30),
+        (LOG_FAILURE, 40),
+    )
+
+
+#
+# Job results
+#
+
+class JobResultStatusChoices(ChoiceSet):
+
+    STATUS_PENDING = 'pending'
+    STATUS_RUNNING = 'running'
+    STATUS_COMPLETED = 'completed'
+    STATUS_ERRORED = 'errored'
+    STATUS_FAILED = 'failed'
+
+    CHOICES = (
+        (STATUS_PENDING, 'Pending'),
+        (STATUS_RUNNING, 'Running'),
+        (STATUS_COMPLETED, 'Completed'),
+        (STATUS_ERRORED, 'Errored'),
+        (STATUS_FAILED, 'Failed'),
+    )
+
+    TERMINAL_STATE_CHOICES = (
+        STATUS_COMPLETED,
+        STATUS_ERRORED,
+        STATUS_FAILED,
+    )
+
+
 #
 # Webhooks
 #

+ 2 - 15
netbox/extras/constants.py

@@ -1,17 +1,3 @@
-# Report logging levels
-LOG_DEFAULT = 0
-LOG_SUCCESS = 10
-LOG_INFO = 20
-LOG_WARNING = 30
-LOG_FAILURE = 40
-LOG_LEVEL_CODES = {
-    LOG_DEFAULT: 'default',
-    LOG_SUCCESS: 'success',
-    LOG_INFO: 'info',
-    LOG_WARNING: 'warning',
-    LOG_FAILURE: 'failure',
-}
-
 # Webhook content types
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
@@ -19,7 +5,8 @@ HTTP_CONTENT_TYPE_JSON = 'application/json'
 EXTRAS_FEATURES = [
     'custom_fields',
     'custom_links',
-    'graphs',
     'export_templates',
+    'graphs',
+    'job_results',
     'webhooks'
 ]

+ 31 - 1
netbox/extras/filters.py

@@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.filters import BaseFilterSet
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
-from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
+from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
 
 
 __all__ = (
@@ -287,3 +287,33 @@ class CreatedUpdatedFilterSet(django_filters.FilterSet):
         field_name='last_updated',
         lookup_expr='lte'
     )
+
+
+#
+# Job Results
+#
+
+class JobResultFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    created = django_filters.DateTimeFilter()
+    completed = django_filters.DateTimeFilter()
+    status = django_filters.MultipleChoiceFilter(
+        choices=JobResultStatusChoices,
+        null_value=None
+    )
+
+    class Meta:
+        model = JobResult
+        fields = [
+            'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
+        ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user__username__icontains=value)
+        )

+ 22 - 0
netbox/extras/migrations/0043_report.py

@@ -0,0 +1,22 @@
+# Generated by Django 3.0.7 on 2020-06-23 02:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0042_customfield_manager'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Report',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+            ],
+            options={
+                'managed': False,
+            },
+        )
+    ]

+ 75 - 0
netbox/extras/migrations/0044_jobresult.py

@@ -0,0 +1,75 @@
+import uuid
+
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+import extras.utils
+from extras.choices import JobResultStatusChoices
+
+
+def convert_job_results(apps, schema_editor):
+    """
+    Convert ReportResult objects to JobResult objects
+    """
+    Report = apps.get_model('extras', 'Report')
+    ReportResult = apps.get_model('extras', 'ReportResult')
+    JobResult = apps.get_model('extras', 'JobResult')
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    report_content_type = ContentType.objects.get_for_model(Report)
+
+    job_results = []
+    for report_result in ReportResult.objects.all():
+        if report_result.failed:
+            status = JobResultStatusChoices.STATUS_FAILED
+        else:
+            status = JobResultStatusChoices.STATUS_COMPLETED
+        job_results.append(
+            JobResult(
+                name=report_result.report,
+                obj_type=report_content_type,
+                created=report_result.created,
+                completed=report_result.created,
+                user=report_result.user,
+                status=status,
+                data=report_result.data,
+                job_id=uuid.uuid4()
+            )
+        )
+    JobResult.objects.bulk_create(job_results)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('extras', '0043_report'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='JobResult',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=255)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('completed', models.DateTimeField(blank=True, null=True)),
+                ('status', models.CharField(default='pending', max_length=30)),
+                ('data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
+                ('job_id', models.UUIDField(unique=True)),
+                ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('job_results'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.ContentType')),
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['obj_type', 'name', '-created'],
+            },
+        ),
+        migrations.RunPython(
+            code=convert_job_results
+        ),
+        migrations.DeleteModel(
+            name='ReportResult'
+        )
+    ]

+ 4 - 3
netbox/extras/models/__init__.py

@@ -1,7 +1,7 @@
 from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
 from .models import (
-    ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
-    Script, Webhook,
+    ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange,
+    Report, Script, Webhook,
 )
 from .tags import Tag, TaggedItem
 
@@ -16,8 +16,9 @@ __all__ = (
     'ExportTemplate',
     'Graph',
     'ImageAttachment',
+    'JobResult',
     'ObjectChange',
-    'ReportResult',
+    'Report',
     'Script',
     'Tag',
     'TaggedItem',

+ 93 - 13
netbox/extras/models/models.py

@@ -1,6 +1,8 @@
 import json
+import uuid
 from collections import OrderedDict
 
+import django_rq
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
@@ -10,6 +12,7 @@ from django.db import models
 from django.http import HttpResponse
 from django.template import Template, Context
 from django.urls import reverse
+from django.utils import timezone
 from rest_framework.utils.encoders import JSONEncoder
 
 from utilities.querysets import RestrictedQuerySet
@@ -17,7 +20,7 @@ from utilities.utils import deepmerge, render_jinja2
 from extras.choices import *
 from extras.constants import *
 from extras.querysets import ConfigContextQuerySet
-from extras.utils import FeatureQuery, image_upload
+from extras.utils import extras_features, FeatureQuery, image_upload
 
 
 #
@@ -562,29 +565,56 @@ class ConfigContextModel(models.Model):
 # Custom scripts
 #
 
+@extras_features('job_results')
 class Script(models.Model):
     """
     Dummy model used to generate permissions for custom scripts. Does not exist in the database.
     """
+
+    class Meta:
+        managed = False
+
+
+#
+# Reports
+#
+
+@extras_features('job_results')
+class Report(models.Model):
+    """
+    Dummy model used to generate permissions for reports. Does not exist in the database.
+    """
+
     class Meta:
         managed = False
 
 
 #
-# Report results
+# Job results
 #
 
-class ReportResult(models.Model):
+class JobResult(models.Model):
     """
     This model stores the results from running a user-defined report.
     """
-    report = models.CharField(
-        max_length=255,
-        unique=True
+    name = models.CharField(
+        max_length=255
+    )
+    obj_type = models.ForeignKey(
+        to=ContentType,
+        related_name='job_results',
+        verbose_name='Object types',
+        limit_choices_to=FeatureQuery('job_results'),
+        help_text="The object type to which this job result applies.",
+        on_delete=models.CASCADE,
     )
     created = models.DateTimeField(
         auto_now_add=True
     )
+    completed = models.DateTimeField(
+        null=True,
+        blank=True
+    )
     user = models.ForeignKey(
         to=User,
         on_delete=models.SET_NULL,
@@ -592,19 +622,69 @@ class ReportResult(models.Model):
         blank=True,
         null=True
     )
-    failed = models.BooleanField()
-    data = JSONField()
+    status = models.CharField(
+        max_length=30,
+        choices=JobResultStatusChoices,
+        default=JobResultStatusChoices.STATUS_PENDING
+    )
+    data = JSONField(
+        null=True,
+        blank=True
+    )
+    job_id = models.UUIDField(
+        unique=True
+    )
 
     class Meta:
-        ordering = ['report']
+        ordering = ['obj_type', 'name', '-created']
 
     def __str__(self):
-        return "{} {} at {}".format(
-            self.report,
-            "passed" if not self.failed else "failed",
-            self.created
+        return str(self.job_id)
+
+    @property
+    def duration(self):
+        if not self.completed:
+            return None
+
+        duration = self.completed - self.created
+        minutes, seconds = divmod(duration.total_seconds(), 60)
+
+        return f"{int(minutes)} minutes, {seconds:.2f} seconds"
+
+    def set_status(self, status):
+        """
+        Helper method to change the status of the job result and save. If the target status is terminal, the
+        completion time is also set.
+        """
+        self.status = status
+        if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
+            self.completed = timezone.now()
+
+        self.save()
+
+    @classmethod
+    def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
+        """
+        Create a JobResult instance and enqueue a job using the given callable
+
+        func: The callable object to be enqueued for execution
+        name: Name for the JobResult instance
+        obj_type: ContentType to link to the JobResult instance obj_type
+        user: User object to link to the JobResult instance
+        args: additional args passed to the callable
+        kwargs: additional kargs passed to the callable
+        """
+        job_result = cls.objects.create(
+            name=name,
+            obj_type=obj_type,
+            user=user,
+            job_id=uuid.uuid4()
         )
 
+        func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
+
+        return job_result
+
 
 #
 # Change logging

+ 60 - 20
netbox/extras/reports.py

@@ -5,10 +5,15 @@ import pkgutil
 from collections import OrderedDict
 
 from django.conf import settings
+from django.db.models import Q
 from django.utils import timezone
+from django_rq import job
 
-from .constants import *
-from .models import ReportResult
+from .choices import JobResultStatusChoices, LogLevelChoices
+from .models import JobResult
+
+
+logger = logging.getLogger(__name__)
 
 
 def is_report(obj):
@@ -60,6 +65,32 @@ def get_reports():
     return module_list
 
 
+@job('default')
+def run_report(job_result, *args, **kwargs):
+    """
+    Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
+    method for queueing into the background processor.
+    """
+    module_name, report_name = job_result.name.split('.', 1)
+    report = get_report(module_name, report_name)
+
+    try:
+        report.run(job_result)
+    except Exception as e:
+        print(e)
+        job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
+        logging.error(f"Error during execution of report {job_result.name}")
+
+    # Delete any previous terminal state results
+    JobResult.objects.filter(
+        obj_type=job_result.obj_type,
+        name=job_result.name,
+        status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+    ).exclude(
+        pk=job_result.pk
+    ).delete()
+
+
 class Report(object):
     """
     NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
@@ -115,22 +146,29 @@ class Report(object):
         return self.__module__
 
     @property
-    def name(self):
+    def class_name(self):
         return self.__class__.__name__
 
+    @property
+    def name(self):
+        """
+        Override this attribute to set a custom display name.
+        """
+        return self.class_name
+
     @property
     def full_name(self):
-        return '.'.join([self.__module__, self.__class__.__name__])
+        return f'{self.module}.{self.class_name}'
 
-    def _log(self, obj, message, level=LOG_DEFAULT):
+    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
         """
         Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
         """
-        if level not in LOG_LEVEL_CODES:
+        if level not in LogLevelChoices.as_dict():
             raise Exception("Unknown logging level: {}".format(level))
         self._results[self.active_test]['log'].append((
             timezone.now().isoformat(),
-            LOG_LEVEL_CODES.get(level),
+            level,
             str(obj) if obj else None,
             obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
             message,
@@ -140,7 +178,7 @@ class Report(object):
         """
         Log a message which is not associated with a particular object.
         """
-        self._log(None, message, level=LOG_DEFAULT)
+        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
         self.logger.info(message)
 
     def log_success(self, obj, message=None):
@@ -148,7 +186,7 @@ class Report(object):
         Record a successful test against an object. Logging a message is optional.
         """
         if message:
-            self._log(obj, message, level=LOG_SUCCESS)
+            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
         self._results[self.active_test]['success'] += 1
         self.logger.info(f"Success | {obj}: {message}")
 
@@ -156,7 +194,7 @@ class Report(object):
         """
         Log an informational message.
         """
-        self._log(obj, message, level=LOG_INFO)
+        self._log(obj, message, level=LogLevelChoices.LOG_INFO)
         self._results[self.active_test]['info'] += 1
         self.logger.info(f"Info | {obj}: {message}")
 
@@ -164,7 +202,7 @@ class Report(object):
         """
         Log a warning.
         """
-        self._log(obj, message, level=LOG_WARNING)
+        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
         self._results[self.active_test]['warning'] += 1
         self.logger.info(f"Warning | {obj}: {message}")
 
@@ -172,32 +210,34 @@ class Report(object):
         """
         Log a failure. Calling this method will automatically mark the report as failed.
         """
-        self._log(obj, message, level=LOG_FAILURE)
+        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
         self._results[self.active_test]['failure'] += 1
         self.logger.info(f"Failure | {obj}: {message}")
         self.failed = True
 
-    def run(self):
+    def run(self, job_result):
         """
-        Run the report and return 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")
+        job_result.status = JobResultStatusChoices.STATUS_RUNNING
+        job_result.save()
 
         for method_name in self.test_methods:
             self.active_test = method_name
             test_method = getattr(self, method_name)
             test_method()
 
-        # Delete any previous ReportResult and create a new one to record the result.
-        ReportResult.objects.filter(report=self.full_name).delete()
-        result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
-        result.save()
-        self.result = result
-
         if self.failed:
             self.logger.warning("Report failed")
+            job_result.status = JobResultStatusChoices.STATUS_FAILED
         else:
             self.logger.info("Report completed successfully")
+            job_result.status = JobResultStatusChoices.STATUS_COMPLETED
+
+        job_result.data = self._results
+        job_result.completed = timezone.now()
+        job_result.save()
 
         # Perform any post-run tasks
         self.post_run()

+ 50 - 22
netbox/extras/scripts.py

@@ -12,12 +12,17 @@ from django import forms
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.db import transaction
+from django.utils import timezone
+from django.utils.decorators import classproperty
+from django_rq import job
 from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
 from mptt.models import MPTTModel
 
+from extras.api.serializers import ScriptOutputSerializer
+from extras.choices import JobResultStatusChoices, LogLevelChoices
+from extras.models import JobResult
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
-from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
 from utilities.exceptions import AbortTransaction
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from .forms import ScriptForm
@@ -267,8 +272,20 @@ class BaseScript:
         self.source = inspect.getsource(self.__class__)
 
     def __str__(self):
+        return self.name
+
+    @classproperty
+    def name(self):
         return getattr(self.Meta, 'name', self.__class__.__name__)
 
+    @classproperty
+    def full_name(self):
+        return '.'.join([self.__module__, self.__name__])
+
+    @classproperty
+    def description(self):
+        return getattr(self.Meta, 'description', '')
+
     @classmethod
     def module(cls):
         return cls.__module__
@@ -306,23 +323,23 @@ class BaseScript:
 
     def log_debug(self, message):
         self.logger.log(logging.DEBUG, message)
-        self.log.append((LOG_DEFAULT, message))
+        self.log.append((LogLevelChoices.LOG_DEFAULT, message))
 
     def log_success(self, message):
         self.logger.log(logging.INFO, message)  # No syslog equivalent for SUCCESS
-        self.log.append((LOG_SUCCESS, message))
+        self.log.append((LogLevelChoices.LOG_SUCCESS, message))
 
     def log_info(self, message):
         self.logger.log(logging.INFO, message)
-        self.log.append((LOG_INFO, message))
+        self.log.append((LogLevelChoices.LOG_INFO, message))
 
     def log_warning(self, message):
         self.logger.log(logging.WARNING, message)
-        self.log.append((LOG_WARNING, message))
+        self.log.append((LogLevelChoices.LOG_WARNING, message))
 
     def log_failure(self, message):
         self.logger.log(logging.ERROR, message)
-        self.log.append((LOG_FAILURE, message))
+        self.log.append((LogLevelChoices.LOG_FAILURE, message))
 
     # Convenience functions
 
@@ -375,17 +392,21 @@ def is_variable(obj):
     return isinstance(obj, ScriptVariable)
 
 
-def run_script(script, data, request, commit=True):
+@job('default')
+def run_script(data, request, commit=True, *args, **kwargs):
     """
     A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
     exists outside of the Script class to ensure it cannot be overridden by a script author.
     """
-    output = None
-    start_time = None
-    end_time = None
+    job_result = kwargs.pop('job_result')
+    module, script_name = job_result.name.split('.', 1)
+
+    script = get_script(module, script_name)()
+
+    job_result.status = JobResultStatusChoices.STATUS_RUNNING
+    job_result.save()
 
-    script_name = script.__class__.__name__
-    logger = logging.getLogger(f"netbox.scripts.{script.module()}.{script_name}")
+    logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}")
     logger.info(f"Running script (commit={commit})")
 
     # Add files to form data
@@ -405,13 +426,16 @@ def run_script(script, data, request, commit=True):
 
     try:
         with transaction.atomic():
-            start_time = time.time()
-            output = script.run(**kwargs)
-            end_time = time.time()
+            script.output = script.run(**kwargs)
+            job_result.data = ScriptOutputSerializer(script).data
+            job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
+
             if not commit:
                 raise AbortTransaction()
+
     except AbortTransaction:
         pass
+
     except Exception as e:
         stacktrace = traceback.format_exc()
         script.log_failure(
@@ -419,6 +443,8 @@ def run_script(script, data, request, commit=True):
         )
         logger.error(f"Exception raised during script execution: {e}")
         commit = False
+        job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
+
     finally:
         if not commit:
             # Delete all pending changelog entries
@@ -427,14 +453,16 @@ def run_script(script, data, request, commit=True):
                 "Database changes have been reverted automatically."
             )
 
-    # Calculate execution time
-    if end_time is not None:
-        execution_time = end_time - start_time
-        logger.info(f"Script completed in {execution_time:.4f} seconds")
-    else:
-        execution_time = None
+    logger.info(f"Script completed in {job_result.duration}")
 
-    return output, execution_time
+    # Delete any previous terminal state results
+    JobResult.objects.filter(
+        obj_type=job_result.obj_type,
+        name=job_result.name,
+        status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+    ).exclude(
+        pk=job_result.pk
+    ).delete()
 
 
 def get_scripts(use_names=False):

+ 4 - 24
netbox/extras/templatetags/log_levels.py

@@ -1,6 +1,6 @@
 from django import template
 
-from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
+from extras.choices import LogLevelChoices
 
 
 register = template.Library()
@@ -11,27 +11,7 @@ def log_level(level):
     """
     Display a label indicating a syslog severity (e.g. info, warning, etc.).
     """
-    levels = {
-        LOG_DEFAULT: {
-            'name': 'Default',
-            'class': 'default'
-        },
-        LOG_SUCCESS: {
-            'name': 'Success',
-            'class': 'success',
-        },
-        LOG_INFO: {
-            'name': 'Info',
-            'class': 'info'
-        },
-        LOG_WARNING: {
-            'name': 'Warning',
-            'class': 'warning'
-        },
-        LOG_FAILURE: {
-            'name': 'Failure',
-            'class': 'danger'
-        }
+    return {
+        'name': LogLevelChoices.as_dict()[level],
+        'class': dict(LogLevelChoices.CLASS_MAP)[level]
     }
-
-    return levels[level]

+ 35 - 8
netbox/extras/tests/test_api.py

@@ -6,8 +6,9 @@ from django.utils import timezone
 from rest_framework import status
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
-from extras.api.views import ScriptViewSet
+from extras.api.views import ReportViewSet, ScriptViewSet
 from extras.models import ConfigContext, Graph, ExportTemplate, Tag
+from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 
@@ -207,6 +208,38 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(rendered_context['bar'], 456)
 
 
+class ReportTest(APITestCase):
+
+    class TestReport(Report):
+
+        def test_foo(self):
+            self.log_success(None, "Report completed")
+
+    def get_test_report(self, *args):
+        return self.TestReport()
+
+    def setUp(self):
+        super().setUp()
+
+        # Monkey-patch the API viewset's _get_script method to return our test script above
+        ReportViewSet._retrieve_report = self.get_test_report
+
+    def test_get_report(self):
+        url = reverse('extras-api:report-detail', kwargs={'pk': None})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.TestReport.__name__)
+
+    def test_run_report(self):
+        self.add_permissions('extras.run_script')
+
+        url = reverse('extras-api:report-run', kwargs={'pk': None})
+        response = self.client.post(url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        self.assertEqual(response.data['result']['status']['value'], 'pending')
+
+
 class ScriptTest(APITestCase):
 
     class TestScript(Script):
@@ -263,13 +296,7 @@ class ScriptTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
-        self.assertEqual(response.data['log'][0]['status'], 'info')
-        self.assertEqual(response.data['log'][0]['message'], script_data['var1'])
-        self.assertEqual(response.data['log'][1]['status'], 'success')
-        self.assertEqual(response.data['log'][1]['message'], script_data['var2'])
-        self.assertEqual(response.data['log'][2]['status'], 'failure')
-        self.assertEqual(response.data['log'][2]['message'], script_data['var3'])
-        self.assertEqual(response.data['output'], 'Script complete')
+        self.assertEqual(response.data['result']['status']['value'], 'pending')
 
 
 class CreatedUpdatedFilterTest(APITestCase):

+ 4 - 3
netbox/extras/urls.py

@@ -36,11 +36,12 @@ urlpatterns = [
 
     # Reports
     path('reports/', views.ReportListView.as_view(), name='report_list'),
-    path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
-    path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
+    path('reports/<str:module>.<str:name>/', views.ReportView.as_view(), name='report'),
+    path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
 
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
-    path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
+    path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
+    path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
 
 ]

+ 150 - 53
netbox/extras/views.py

@@ -1,3 +1,5 @@
+import time
+
 from django import template
 from django.conf import settings
 from django.contrib import messages
@@ -13,15 +15,16 @@ from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
-from utilities.utils import shallow_compare_dict
+from utilities.utils import copy_safe_request, shallow_compare_dict
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-    ObjectPermissionRequiredMixin,
+    ContentTypePermissionRequiredMixin,
 )
 from virtualization.models import Cluster, ClusterGroup
 from . import filters, forms, tables
-from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
-from .reports import get_report, get_reports
+from .choices import JobResultStatusChoices
+from .models import ConfigContext, ImageAttachment, ObjectChange, Report, JobResult, Script, Tag, TaggedItem
+from .reports import get_report, get_reports, run_report
 from .scripts import get_scripts, run_script
 
 
@@ -314,9 +317,9 @@ class ImageAttachmentDeleteView(ObjectDeleteView):
 # Reports
 #
 
-class ReportListView(ObjectPermissionRequiredMixin, View):
+class ReportListView(ContentTypePermissionRequiredMixin, View):
     """
-    Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
+    Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
     """
     def get_required_permission(self):
         return 'extras.view_reportresult'
@@ -324,7 +327,14 @@ class ReportListView(ObjectPermissionRequiredMixin, View):
     def get(self, request):
 
         reports = get_reports()
-        results = {r.report: r for r in ReportResult.objects.all()}
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        results = {
+            r.name: r
+            for r in JobResult.objects.filter(
+                obj_type=report_content_type,
+                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            ).defer('data')
+        }
 
         ret = []
         for module, report_list in reports:
@@ -339,89 +349,148 @@ class ReportListView(ObjectPermissionRequiredMixin, View):
         })
 
 
-class ReportView(ObjectPermissionRequiredMixin, View):
+class GetReportMixin:
+    def _get_report(self, name, module=None):
+        if module is None:
+            module, name = name.split('.', 1)
+        report = get_report(module, name)
+        if report is None:
+            raise Http404
+
+        return report
+
+
+class ReportView(GetReportMixin, ContentTypePermissionRequiredMixin, View):
     """
-    Display a single Report and its associated ReportResult (if any).
+    Display a single Report and its associated JobResult (if any).
     """
     def get_required_permission(self):
         return 'extras.view_reportresult'
 
-    def get(self, request, name):
+    def get(self, request, module, name):
 
-        # Retrieve the Report by "<module>.<report>"
-        module_name, report_name = name.split('.')
-        report = get_report(module_name, report_name)
-        if report is None:
-            raise Http404
+        report = self._get_report(name, module)
 
-        # Attach the ReportResult (if any)
-        report.result = ReportResult.objects.filter(report=report.full_name).first()
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        report.result = JobResult.objects.filter(
+            obj_type=report_content_type,
+            name=report.full_name,
+            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+        ).first()
 
         return render(request, 'extras/report.html', {
             'report': report,
             'run_form': ConfirmationForm(),
         })
 
+    def post(self, request, module, name):
 
-class ReportRunView(ObjectPermissionRequiredMixin, View):
-    """
-    Run a Report and record a new ReportResult.
-    """
-    def get_required_permission(self):
-        return 'extras.add_reportresult'
-
-    def post(self, request, name):
+        # Permissions check
+        if not request.user.has_perm('extras.run_report'):
+            return HttpResponseForbidden()
 
-        # Retrieve the Report by "<module>.<report>"
-        module_name, report_name = name.split('.')
-        report = get_report(module_name, report_name)
-        if report is None:
-            raise Http404
+        report = self._get_report(name, module)
 
         form = ConfirmationForm(request.POST)
         if form.is_valid():
 
-            # Run the Report. A new ReportResult is created.
-            report.run()
-            result = 'failed' if report.failed else 'passed'
-            msg = "Ran report {} ({})".format(report.full_name, result)
-            messages.success(request, mark_safe(msg))
+            # Run the Report. A new JobResult is created.
+            report_content_type = ContentType.objects.get(app_label='extras', model='report')
+            job_result = JobResult.enqueue_job(
+                run_report,
+                report.full_name,
+                report_content_type,
+                request.user
+            )
+
+            return redirect('extras:report_result', job_result_pk=job_result.pk)
+
+        return render(request, 'extras/report.html', {
+            'report': report,
+            'run_form': form,
+        })
+
 
-        return redirect('extras:report', name=report.full_name)
+class ReportResultView(ContentTypePermissionRequiredMixin, GetReportMixin, View):
+
+    def get_required_permission(self):
+        return 'extras.view_report'
+
+    def get(self, request, job_result_pk):
+        result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        if result.obj_type != report_content_type:
+            raise Http404
+
+        report = self._get_report(result.name)
+
+        return render(request, 'extras/report_result.html', {
+            'report': report,
+            'result': result,
+            'class_name': report.name,
+            'run_form': ConfirmationForm(),
+        })
 
 
 #
 # Scripts
 #
 
-class ScriptListView(ObjectPermissionRequiredMixin, View):
+class GetScriptMixin:
+    def _get_script(self, name, module=None):
+        if module is None:
+            module, name = name.split('.', 1)
+        scripts = get_scripts()
+        try:
+            return scripts[module][name]()
+        except KeyError:
+            raise Http404
+
+
+class ScriptListView(ContentTypePermissionRequiredMixin, View):
 
     def get_required_permission(self):
         return 'extras.view_script'
 
     def get(self, request):
 
+        scripts = get_scripts(use_names=True)
+        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+        results = {
+            r.name: r
+            for r in JobResult.objects.filter(
+                obj_type=script_content_type,
+                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            ).defer('data')
+        }
+
+        for _scripts in scripts.values():
+            for script in _scripts.values():
+                script.result = results.get(script.full_name)
+
         return render(request, 'extras/script_list.html', {
-            'scripts': get_scripts(use_names=True),
+            'scripts': scripts,
         })
 
 
-class ScriptView(ObjectPermissionRequiredMixin, View):
+class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
 
     def get_required_permission(self):
         return 'extras.view_script'
 
-    def _get_script(self, module, name):
-        scripts = get_scripts()
-        try:
-            return scripts[module][name]()
-        except KeyError:
-            raise Http404
-
     def get(self, request, module, name):
-        script = self._get_script(module, name)
+        script = self._get_script(name, module)
         form = script.as_form(initial=request.GET)
 
+        # Look for a pending JobResult (use the latest one by creation timestamp)
+        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+        script.result = JobResult.objects.filter(
+            obj_type=script_content_type,
+            name=script.full_name,
+        ).exclude(
+            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+        ).first()
+
         return render(request, 'extras/script.html', {
             'module': module,
             'script': script,
@@ -434,19 +503,47 @@ class ScriptView(ObjectPermissionRequiredMixin, View):
         if not request.user.has_perm('extras.run_script'):
             return HttpResponseForbidden()
 
-        script = self._get_script(module, name)
+        script = self._get_script(name, module)
         form = script.as_form(request.POST, request.FILES)
-        output = None
-        execution_time = None
 
         if form.is_valid():
             commit = form.cleaned_data.pop('_commit')
-            output, execution_time = run_script(script, form.cleaned_data, request, commit)
+
+            script_content_type = ContentType.objects.get(app_label='extras', model='script')
+            job_result = JobResult.enqueue_job(
+                run_script,
+                script.full_name,
+                script_content_type,
+                request.user,
+                data=form.cleaned_data,
+                request=copy_safe_request(request),
+                commit=commit
+            )
+
+            return redirect('extras:script_result', job_result_pk=job_result.pk)
 
         return render(request, 'extras/script.html', {
             'module': module,
             'script': script,
             'form': form,
-            'output': output,
-            'execution_time': execution_time,
+        })
+
+
+class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
+
+    def get_required_permission(self):
+        return 'extras.view_script'
+
+    def get(self, request, job_result_pk):
+        result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
+        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+        if result.obj_type != script_content_type:
+            raise Http404
+
+        script = self._get_script(result.name)
+
+        return render(request, 'extras/script_result.html', {
+            'script': script,
+            'result': result,
+            'class_name': script.__class__.__name__
         })

+ 11 - 2
netbox/netbox/views.py

@@ -1,6 +1,7 @@
 from collections import OrderedDict
 
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, F
 from django.shortcuts import render
 from django.views.generic import View
@@ -24,7 +25,8 @@ from dcim.tables import (
     CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
     VirtualChassisTable,
 )
-from extras.models import ObjectChange, ReportResult
+from extras.choices import JobResultStatusChoices
+from extras.models import ObjectChange, JobResult
 from ipam.filters import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
@@ -187,6 +189,13 @@ class HomeView(View):
             pk__lt=F('_connected_interface')
         )
 
+        # Report Results
+        report_content_type = ContentType.objects.get(app_label='extras', model='report')
+        report_results = JobResult.objects.filter(
+            obj_type=report_content_type,
+            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+        ).defer('data')[:10]
+
         stats = {
 
             # Organization
@@ -241,7 +250,7 @@ class HomeView(View):
         return render(request, self.template_name, {
             'search_form': SearchForm(),
             'stats': stats,
-            'report_results': ReportResult.objects.order_by('-created')[:10],
+            'report_results': report_results,
             'changelog': changelog[:15],
             'new_release': new_release,
         })

+ 47 - 0
netbox/project-static/js/job_result.js

@@ -0,0 +1,47 @@
+var url = netbox_api_path + "extras/job-results/";
+var timeout = 1000;
+
+function updatePendingStatusLabel(status){
+    var labelClass;
+    if (status.value === 'failed' || status.value === 'errored'){
+        labelClass = 'danger';
+    } else if (status.value === 'running'){
+        labelClass = 'warning';
+    } else if (status.value === 'completed'){
+        labelClass = 'success';
+    } else {
+        labelClass = 'default';
+    }
+    var elem = $('#pending-result-label > label');
+    elem.attr('class', 'label label-' + labelClass);
+    elem.text(status.label);
+}
+
+function refreshWindow(){
+    window.location.reload();
+}
+
+$(document).ready(function(){
+    if (pending_result_id !== null){
+        (function checkPendingResult(){
+            $.ajax({
+                url: url + pending_result_id + '/',
+                method: 'GET',
+                dataType: 'json',
+                context: this,
+                success: function(data) {
+                    updatePendingStatusLabel(data.status);
+                    if (data.status.value === 'completed' || data.status.value === 'failed' || data.status.value === 'errored'){
+                        jobTerminatedAction()
+                    } else {
+                        setTimeout(checkPendingResult, timeout);
+                        if (timeout < 10000) {
+                            // back off each iteration, until we reach a 10s interval
+                            timeout += 1000
+                        }
+                    }
+                }
+            });
+        })();
+    }
+})

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

@@ -0,0 +1,13 @@
+{% if result.status == 'failed' %}
+    <label class="label label-danger">Failed</label>
+{% elif result.status == 'errored' %}
+    <label class="label label-danger">Errored</label>
+{% elif result.status == 'pending' %}
+    <label class="label label-default">Pending</label>
+{% elif result.status == 'running' %}
+    <label class="label label-warning">Running</label>
+{% elif result.status == 'completed' %}
+    <label class="label label-success">Completed</label>
+{% else %}
+    <label class="label label-default">N/A</label>
+{% endif %}

+ 0 - 7
netbox/templates/extras/inc/report_label.html

@@ -1,7 +0,0 @@
-{% if result.failed %}
-    <label class="label label-danger">Failed</label>
-{% elif result %}
-    <label class="label label-success">Passed</label>
-{% else %}
-    <label class="label label-default">N/A</label>
-{% endif %}

+ 6 - 70
netbox/templates/extras/report.html

@@ -13,89 +13,25 @@
             </ol>
         </div>
     </div>
-    {% if perms.extras.add_reportresult %}
+    {% if perms.extras.run_report %}
         <div class="pull-right noprint">
-            <form action="{% url 'extras:report_run' name=report.full_name %}" method="post">
+            <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
                 {% csrf_token %}
                 {{ run_form }}
                 <button type="submit" name="_run" class="btn btn-primary"><i class="fa fa-play"></i> Run Report</button>
             </form>
         </div>
     {% endif %}
-    <h1>{{ report.name }}{% include 'extras/inc/report_label.html' with result=report.result %}</h1>
+    <h1>{{ report.name }}</h1>
     <div class="row">
         <div class="col-md-12">
             {% if report.description %}
                 <p class="lead">{{ report.description }}</p>
             {% endif %}
             {% if report.result %}
-                <p>Last run: <strong>{{ report.result.created }}</strong></p>
-            {% endif %}
-            {% if report.result %}
-                <div class="panel panel-default">
-                    <div class="panel-heading">
-                        <strong>Report Methods</strong>
-                    </div>
-                    <table class="table table-hover panel-body">
-                        {% for method, data in report.result.data.items %}
-                            <tr>
-                                <td><code><a href="#{{ method }}">{{ method }}</a></code></td>
-                                <td class="text-right report-stats">
-                                    <label class="label label-success">{{ data.success }}</label>
-                                    <label class="label label-info">{{ data.info }}</label>
-                                    <label class="label label-warning">{{ data.warning }}</label>
-                                    <label class="label label-danger">{{ data.failure }}</label>
-                                </td>
-                            </tr>
-                        {% endfor %}
-                    </table>
-                </div>
-                <div class="panel panel-default">
-                    <div class="panel-heading">
-                        <strong>Report Results</strong>
-                    </div>
-                    <table class="table table-hover panel-body report">
-                        <thead>
-                            <tr class="table-headings">
-                                <th>Time</th>
-                                <th>Level</th>
-                                <th>Object</th>
-                                <th>Message</th>
-                            </tr>
-                        </thead>
-                        <tbody>
-                            {% for method, data in report.result.data.items %}
-                                <tr>
-                                    <th colspan="4" style="font-family: monospace">
-                                        <a name="{{ method }}"></a>{{ method }}
-                                    </th>
-                                </tr>
-                                {% for time, level, obj, url, message in data.log %}
-                                    <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
-                                        <td>{{ time }}</td>
-                                        <td>
-                                            <label class="label label-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
-                                        </td>
-                                        <td>
-                                            {% if obj and url %}
-                                                <a href="{{ url }}">{{ obj }}</a>
-                                            {% elif obj %}
-                                                {{ obj }}
-                                            {% endif %}
-                                        </td>
-                                        <td>{{ message }}</td>
-                                    </tr>
-                                {% endfor %}
-                            {% endfor %}
-                        </tbody>
-                    </table>
-                </div>
-            {% else %}
-                <div class="well">No results are available for this report. Please run the report first.</div>
-            {% endif %}
-        </div>
-        <div class="col-md-3">
-            {% if report.result %}
+                <p>Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
+                    <strong>{{ report.result.created }}</strong>
+                </a></p>
             {% endif %}
         </div>
     </div>

+ 15 - 11
netbox/templates/extras/report_list.html

@@ -6,7 +6,7 @@
     <div class="row">
         <div class="col-md-9">
             {% if reports %}
-            {% for module, module_reports in reports %}
+                {% for module, module_reports in reports %}
                     <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
                     <table class="table table-hover table-headings reports">
                         <thead>
@@ -21,17 +21,21 @@
                             {% for report in module_reports %}
                                 <tr>
                                     <td>
-                                        <a href="{% url 'extras:report' name=report.full_name %}" name="report.{{ report.name }}"><strong>{{ report.name }}</strong></a>
+                                        <a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">
+                                            <strong>{{ report.name }}</strong>
+                                        </a>
                                     </td>
                                     <td>
-                                        {% include 'extras/inc/report_label.html' with result=report.result %}
+                                        {% include 'extras/inc/job_label.html' with result=report.result %}
+                                    </td>
+                                    <td>{{ report.description|placeholder }}</td>
+                                    <td class="text-right">
+                                        {% if report.result %}
+                                            <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created }}</a>
+                                        {% else %}
+                                            <span class="text-muted">Never</span>
+                                        {% endif %}
                                     </td>
-                                    <td>{{ report.description|default:"" }}</td>
-                                    {% if report.result %}
-                                        <td class="text-right">{{ report.result.created }}</td>
-                                    {% else %}
-                                        <td class="text-right text-muted">Never</td>
-                                    {% endif %}
                                 </tr>
                                 {% for method, stats in report.result.data.items %}
                                     <tr>
@@ -66,10 +70,10 @@
                         </div>
                         <ul class="list-group">
                             {% for report in module_reports %}
-                                <a href="#report.{{ report.name }}" class="list-group-item">
+                                <a href="#{{ report.module }}.{{ report.class_name }}" class="list-group-item">
                                     <i class="fa fa-list-alt"></i> {{ report.name }}
                                     <div class="pull-right">
-                                        {% include 'extras/inc/report_label.html' with result=report.result %}
+                                        {% include 'extras/inc/job_label.html' with result=report.result %}
                                     </div>
                                 </a>
                             {% endfor %}

+ 124 - 0
netbox/templates/extras/report_result.html

@@ -0,0 +1,124 @@
+{% extends 'base.html' %}
+{% load helpers %}
+{% load static %}
+
+{% block title %}{{ report.name }}{% endblock %}
+
+{% block content %}
+    <div class="row noprint">
+        <div class="col-md-12">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:report_list' %}">Reports</a></li>
+                <li><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
+                <li>{{ report.name }}</li>
+            </ol>
+        </div>
+    </div>
+    {% if perms.extras.run_report %}
+        <div class="pull-right noprint">
+            <form action="{% url 'extras:report' module=report.module name=report.name %}" method="post">
+                {% csrf_token %}
+                {{ run_form }}
+                <button type="submit" name="_run" class="btn btn-primary"><i class="fa fa-play"></i> Run Report</button>
+            </form>
+        </div>
+    {% endif %}
+    <h1>{{ report.name }}</h1>
+    <div class="row">
+        <div class="col-md-12">
+            {% if report.description %}
+                <p class="lead">{{ report.description }}</p>
+            {% endif %}
+            <p>
+                Run: <strong>{{ result.created }}</strong>
+                {% if result.completed %}
+                    Duration: <strong>{{ result.duration }}</strong>
+                {% else %}
+                    <img id="pending-result-loader" src="{% static 'img/ajax-loader.gif' %}" />
+                {% endif %}
+                <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
+            </p>
+            {% if result.completed and result.status != 'errored' %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Report Methods</strong>
+                    </div>
+                    <table class="table table-hover panel-body">
+                        {% for method, data in result.data.items %}
+                            <tr>
+                                <td><code><a href="#{{ method }}">{{ method }}</a></code></td>
+                                <td class="text-right report-stats">
+                                    <label class="label label-success">{{ data.success }}</label>
+                                    <label class="label label-info">{{ data.info }}</label>
+                                    <label class="label label-warning">{{ data.warning }}</label>
+                                    <label class="label label-danger">{{ data.failure }}</label>
+                                </td>
+                            </tr>
+                        {% endfor %}
+                    </table>
+                </div>
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Report Results</strong>
+                    </div>
+                    <table class="table table-hover panel-body report">
+                        <thead>
+                            <tr class="table-headings">
+                                <th>Time</th>
+                                <th>Level</th>
+                                <th>Object</th>
+                                <th>Message</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for method, data in result.data.items %}
+                                <tr>
+                                    <th colspan="4" style="font-family: monospace">
+                                        <a name="{{ method }}"></a>{{ method }}
+                                    </th>
+                                </tr>
+                                {% for time, level, obj, url, message in data.log %}
+                                    <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
+                                        <td>{{ time }}</td>
+                                        <td>
+                                            <label class="label label-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
+                                        </td>
+                                        <td>
+                                            {% if obj and url %}
+                                                <a href="{{ url }}">{{ obj }}</a>
+                                            {% elif obj %}
+                                                {{ obj }}
+                                            {% endif %}
+                                        </td>
+                                        <td>{{ message }}</td>
+                                    </tr>
+                                {% endfor %}
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            {% elif result.status == 'errored' %}
+                <div class="well">Error during report execution</div>
+            {% else %}
+            <div class="well">Pending results</div>
+            {% endif %}
+        </div>
+    </div>
+{% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+{% if not result.completed %}
+var pending_result_id = {{ result.pk }};
+{% else %}
+var pending_result_id = null;
+{% endif %}
+
+function jobTerminatedAction(){
+    refreshWindow();
+}
+
+</script>
+<script src="{% static 'js/job_result.js' %}?v{{ settings.VERSION }}"
+        onerror="window.location='{% url 'media_failure' %}?filename=js/job_result.js'"></script>
+{% endblock %}

+ 0 - 42
netbox/templates/extras/script.html

@@ -21,51 +21,12 @@
         <li role="presentation" class="active">
             <a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
         </li>
-        <li role="presentation"{% if not output %} class="disabled"{% endif %}>
-            <a href="#output" role="tab" data-toggle="tab">Output</a>
-        </li>
         <li role="presentation">
             <a href="#source" role="tab" data-toggle="tab">Source</a>
         </li>
     </ul>
     <div class="tab-content">
         <div role="tabpanel" class="tab-pane active" id="run">
-            {% if execution_time or script.log %}
-                <div class="row">
-                    <div class="col-md-12">
-                        <div class="panel panel-default">
-                            <div class="panel-heading">
-                                <strong>Script Log</strong>
-                            </div>
-                            <table class="table table-hover panel-body">
-                                <tr>
-                                    <th>Line</th>
-                                    <th>Level</th>
-                                    <th>Message</th>
-                                </tr>
-                                {% for level, message in script.log %}
-                                    <tr>
-                                        <td>{{ forloop.counter }}</td>
-                                        <td>{% log_level level %}</td>
-                                        <td class="rendered-markdown">{{ message|render_markdown }}</td>
-                                    </tr>
-                                {% empty %}
-                                    <tr>
-                                        <td colspan="3" class="text-center text-muted">
-                                            No log output
-                                        </td>
-                                    </tr>
-                                {% endfor %}
-                            </table>
-                            {% if execution_time %}
-                                <div class="panel-footer text-right text-muted">
-                                    <small>Exec time: {{ execution_time|floatformat:3 }}s</small>
-                                </div>
-                            {% endif %}
-                        </div>
-                    </div>
-                </div>
-            {% endif %}
             <div class="row">
                 <div class="col-md-6 col-md-offset-3">
                     {% if not perms.extras.run_script %}
@@ -100,9 +61,6 @@
                 </div>
             </div>
         </div>
-        <div role="tabpanel" class="tab-pane" id="output">
-            <pre>{{ output }}</pre>
-        </div>
         <div role="tabpanel" class="tab-pane" id="source">
             <p><code>{{ script.filename }}</code></p>
             <pre>{{ script.source }}</pre>

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

@@ -4,15 +4,17 @@
 {% block content %}
     <h1>{% block title %}Scripts{% endblock %}</h1>
     <div class="row">
-        <div class="col-md-12">
+        <div class="col-md-9">
             {% if scripts %}
                 {% for module, module_scripts in scripts.items %}
                     <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
                     <table class="table table-hover table-headings reports">
                         <thead>
                             <tr>
-                                <th class="col-md-3">Name</th>
-                                <th class="col-md-9">Description</th>
+                                <th>Name</th>
+                                <th>Status</th>
+                                <th>Description</th>
+                                <th class="text-right">Last Run</th>
                             </tr>
                         </thead>
                         <tbody>
@@ -21,7 +23,15 @@
                                     <td>
                                         <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
                                     </td>
+                                    <td>
+                                        {% include 'extras/inc/job_label.html' with result=script.result %}
+                                    </td>
                                     <td>{{ script.Meta.description }}</td>
+                                    {% if script.result %}
+                                        <td class="text-right">{{ script.result.created }}</td>
+                                    {% else %}
+                                        <td class="text-right text-muted">Never</td>
+                                    {% endif %}
                                 </tr>
                             {% endfor %}
                         </tbody>
@@ -34,5 +44,26 @@
                 </div>
             {% endif %}
         </div>
+        <div class="col-md-3">
+            {% if scripts %}
+                <div class="panel panel-default">
+                    {% for module, module_scripts in scripts.items %}
+                        <div class="panel-heading">
+                            <strong>{{ module|bettertitle }}</strong>
+                        </div>
+                        <ul class="list-group">
+                            {% for class_name, script in module_scripts.items %}
+                                <a href="#script.{{ class_name }}" class="list-group-item">
+                                    <i class="fa fa-list-alt"></i> {{ script.name }}
+                                    <div class="pull-right">
+                                        {% include 'extras/inc/job_label.html' with result=script.result %}
+                                    </div>
+                                </a>
+                            {% endfor %}
+                        </ul>
+                    {% endfor %}
+                </div>
+            {% endif %}
+        </div>
     </div>
 {% endblock %}

+ 119 - 0
netbox/templates/extras/script_result.html

@@ -0,0 +1,119 @@
+{% extends 'base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+{% load log_levels %}
+{% load static %}
+
+{% block title %}{{ script }}{% endblock %}
+
+{% block content %}
+    <div class="row noprint">
+        <div class="col-md-12">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
+                <li><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
+                <li><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
+                <li>{{ result.created }}</li>
+            </ol>
+        </div>
+    </div>
+    <h1>{{ script }}</h1>
+    <p>{{ script.Meta.description }}</p>
+    <ul class="nav nav-tabs" role="tablist">
+        <li role="presentation" class="active">
+            <a href="#log" role="tab" data-toggle="tab" class="active">Log</a>
+        </li>
+        <li role="presentation">
+            <a href="#output" role="tab" data-toggle="tab">Output</a>
+        </li>
+        <li role="presentation">
+            <a href="#source" role="tab" data-toggle="tab">Source</a>
+        </li>
+    </ul>
+    <div class="tab-content">
+        <p>
+            Run: <strong>{{ result.created }}</strong>
+            {% if result.completed %}
+                Duration: <strong>{{ result.duration }}</strong>
+            {% else %}
+                <img id="pending-result-loader" src="{% static 'img/ajax-loader.gif' %}" />
+            {% endif %}
+            <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
+        </p>
+        <div role="tabpanel" class="tab-pane active" id="log">
+            {% if result.completed and result.status != 'errored' %}
+                <div class="row">
+                    <div class="col-md-12">
+                        <div class="panel panel-default">
+                            <div class="panel-heading">
+                                <strong>Script Log</strong>
+                            </div>
+                            <table class="table table-hover panel-body">
+                                <tr>
+                                    <th>Line</th>
+                                    <th>Level</th>
+                                    <th>Message</th>
+                                </tr>
+                                {% for log in result.data.log %}
+                                    <tr>
+                                        <td>{{ forloop.counter }}</td>
+                                        <td>{% log_level log.status %}</td>
+                                        <td class="rendered-markdown">{{ log.message|render_markdown }}</td>
+                                    </tr>
+                                {% empty %}
+                                    <tr>
+                                        <td colspan="3" class="text-center text-muted">
+                                            No log output
+                                        </td>
+                                    </tr>
+                                {% endfor %}
+                            </table>
+                            {% if execution_time %}
+                                <div class="panel-footer text-right text-muted">
+                                    <small>Exec time: {{ execution_time|floatformat:3 }}s</small>
+                                </div>
+                            {% endif %}
+                        </div>
+                    </div>
+                </div>
+            {% elif result.stats == 'errored' %}
+                <div class="row">
+                    <div class="col-md-12">
+                        <div class="well">Error during script execution</div>
+                    </div>
+                </div>
+            {% else %}
+                <div class="row">
+                    <div class="col-md-12">
+                        <div class="well">Pending results</div>
+                    </div>
+                </div>
+            {% endif %}
+        </div>
+        <div role="tabpanel" class="tab-pane" id="output">
+            <pre>{{ result.data.output }}</pre>
+        </div>
+        <div role="tabpanel" class="tab-pane" id="source">
+            <p><code>{{ script.filename }}</code></p>
+            <pre>{{ script.source }}</pre>
+        </div>
+    </div>
+{% endblock %}
+
+
+{% block javascript %}
+<script type="text/javascript">
+{% if not result.completed %}
+var pending_result_id = {{ result.pk }};
+{% else %}
+var pending_result_id = null;
+{% endif %}
+
+function jobTerminatedAction(){
+    refreshWindow()
+}
+
+</script>
+<script src="{% static 'js/job_result.js' %}?v{{ settings.VERSION }}"
+        onerror="window.location='{% url 'media_failure' %}?filename=js/job_result.js'"></script>
+{% endblock %}

+ 2 - 2
netbox/templates/home.html

@@ -280,8 +280,8 @@
                 <table class="table table-hover panel-body">
                     {% for result in report_results %}
                         <tr>
-                            <td><a href="{% url 'extras:report' name=result.report %}">{{ result.report }}</a></td>
-                            <td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/report_label.html' %}</span></td>
+                            <td><a href="{% url 'extras:report_result' job_result_pk=result.pk %}">{{ result.name }}</a></td>
+                            <td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/job_label.html' %}</span></td>
                         </tr>
                     {% endfor %}
                 </table>

+ 22 - 0
netbox/utilities/constants.py

@@ -42,3 +42,25 @@ ADVISORY_LOCK_KEYS = {
     'available-prefixes': 100100,
     'available-ips': 100200,
 }
+
+#
+# HTTP Request META safe copy
+#
+
+HTTP_REQUEST_META_SAFE_COPY = [
+    'CONTENT_LENGTH',
+    'CONTENT_TYPE',
+    'HTTP_ACCEPT',
+    'HTTP_ACCEPT_ENCODING',
+    'HTTP_ACCEPT_LANGUAGE',
+    'HTTP_HOST',
+    'HTTP_REFERER',
+    'HTTP_USER_AGENT',
+    'QUERY_STRING',
+    'REMOTE_ADDR',
+    'REMOTE_HOST',
+    'REMOTE_USER',
+    'REQUEST_METHOD',
+    'SERVER_NAME',
+    'SERVER_PORT',
+]

+ 35 - 0
netbox/utilities/utils.py

@@ -5,10 +5,12 @@ from collections import OrderedDict
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
 from django.http import QueryDict
+from django.http.request import HttpRequest
 from jinja2 import Environment
 
 from dcim.choices import CableLengthUnitChoices
 from extras.utils import is_taggable
+from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
 
 
 def csv_format(data):
@@ -257,3 +259,36 @@ def flatten_dict(d, prefix='', separator='.'):
         else:
             ret[key] = v
     return ret
+
+
+#
+# Fake request object
+#
+
+class NetBoxFakeRequest:
+    """
+    A fake request object which is explicitly defined at the module level so it is able to be pickled. It simply
+    takes what is passed to it as kwargs on init and sets them as instance variables.
+    """
+    def __init__(self, _dict):
+        self.__dict__ = _dict
+
+
+def copy_safe_request(request):
+    """
+    Copy selected attributes from a request object into a new fake request object. This is needed in places where
+    thread safe pickling of the useful request data is needed.
+    """
+    meta = {
+        k: request.META[k]
+        for k in HTTP_REQUEST_META_SAFE_COPY
+        if k in request.META and isinstance(request.META[k], str)
+    }
+    return NetBoxFakeRequest({
+        'META': meta,
+        'POST': request.POST,
+        'GET': request.GET,
+        'FILES': request.FILES,
+        'user': request.user,
+        'path': request.path
+    })

+ 34 - 0
netbox/utilities/views.py

@@ -41,6 +41,40 @@ from .paginator import EnhancedPaginator, get_paginate_count
 # Mixins
 #
 
+class ContentTypePermissionRequiredMixin(AccessMixin):
+    """
+    Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
+    This is related to ObjectPermissionRequiredMixin, except that is does not enforce object-level permissions,
+    and fits within NetBox's custom permission enforcement system.
+
+    additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
+                            derived from the object type
+    """
+    additional_permissions = list()
+
+    def get_required_permission(self):
+        """
+        Return the specific permission necessary to perform the requested action on an object.
+        """
+        raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
+
+    def has_permission(self):
+        user = self.request.user
+        permission_required = self.get_required_permission()
+
+        # Check that the user has been granted the required permission(s).
+        if user.has_perms((permission_required, *self.additional_permissions)):
+            return True
+
+        return False
+
+    def dispatch(self, request, *args, **kwargs):
+        if not self.has_permission():
+            return self.handle_no_permission()
+
+        return super().dispatch(request, *args, **kwargs)
+
+
 class ObjectPermissionRequiredMixin(AccessMixin):
     """
     Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level