Procházet zdrojové kódy

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

2006 scripts reports background
Jeremy Stretch před 5 roky
rodič
revize
6d0281adc8

+ 6 - 15
netbox/extras/admin.py

@@ -2,7 +2,7 @@ from django import forms
 from django.contrib import admin
 from django.contrib import admin
 
 
 from utilities.forms import LaxURLField
 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
 from .reports import get_report
 
 
 
 
@@ -228,27 +228,18 @@ class ExportTemplateAdmin(admin.ModelAdmin):
 # Reports
 # Reports
 #
 #
 
 
-@admin.register(ReportResult)
-class ReportResultAdmin(admin.ModelAdmin):
+@admin.register(JobResult)
+class JobResultAdmin(admin.ModelAdmin):
     list_display = [
     list_display = [
-        'report', 'active', 'created', 'user', 'passing',
+        'obj_type', 'name', 'created', 'completed', 'user', 'status',
     ]
     ]
     fields = [
     fields = [
-        'report', 'user', 'passing', 'data',
+        'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
     ]
     ]
     list_filter = [
     list_filter = [
-        'failed',
+        'status',
     ]
     ]
     readonly_fields = fields
     readonly_fields = fields
 
 
     def has_add_permission(self, request):
     def has_add_permission(self, request):
         return False
         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 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__ = [
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedConfigContextSerializer',
     'NestedExportTemplateSerializer',
     'NestedExportTemplateSerializer',
     'NestedGraphSerializer',
     'NestedGraphSerializer',
-    'NestedReportResultSerializer',
+    'NestedJobResultSerializer',
     'NestedTagSerializer',
     'NestedTagSerializer',
 ]
 ]
 
 
@@ -44,13 +45,13 @@ class NestedTagSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name', 'slug', 'color']
         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:
     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.choices import *
 from extras.constants import *
 from extras.constants import *
 from extras.models 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 extras.utils import FeatureQuery
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 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:
     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):
 class ReportSerializer(serializers.Serializer):
+    id = serializers.CharField(read_only=True, source="full_name")
     module = serializers.CharField(max_length=255)
     module = serializers.CharField(max_length=255)
     name = serializers.CharField(max_length=255)
     name = serializers.CharField(max_length=255)
     description = serializers.CharField(max_length=255, required=False)
     description = serializers.CharField(max_length=255, required=False)
     test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
     test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
-    result = NestedReportResultSerializer()
+    result = NestedJobResultSerializer()
 
 
 
 
 class ReportDetailSerializer(ReportSerializer):
 class ReportDetailSerializer(ReportSerializer):
-    result = ReportResultSerializer()
+    result = JobResultSerializer()
 
 
 
 
 #
 #
@@ -260,19 +275,12 @@ class ReportDetailSerializer(ReportSerializer):
 #
 #
 
 
 class ScriptSerializer(serializers.Serializer):
 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)
     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):
     def get_vars(self, instance):
         return {
         return {
@@ -280,6 +288,10 @@ class ScriptSerializer(serializers.Serializer):
         }
         }
 
 
 
 
+class ScriptDetailSerializer(ScriptSerializer):
+    result = JobResultSerializer()
+
+
 class ScriptInputSerializer(serializers.Serializer):
 class ScriptInputSerializer(serializers.Serializer):
     data = serializers.JSONField()
     data = serializers.JSONField()
     commit = serializers.BooleanField()
     commit = serializers.BooleanField()
@@ -290,7 +302,7 @@ class ScriptLogMessageSerializer(serializers.Serializer):
     message = serializers.SerializerMethodField(read_only=True)
     message = serializers.SerializerMethodField(read_only=True)
 
 
     def get_status(self, instance):
     def get_status(self, instance):
-        return LOG_LEVEL_CODES.get(instance[0])
+        return instance[0]
 
 
     def get_message(self, instance):
     def get_message(self, instance):
         return instance[1]
         return instance[1]

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

@@ -41,5 +41,8 @@ router.register('scripts', views.ScriptViewSet, basename='script')
 # Change logging
 # Change logging
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('object-changes', views.ObjectChangeViewSet)
 
 
+# Job Results
+router.register('job-results', views.JobResultViewSet)
+
 app_name = 'extras-api'
 app_name = 'extras-api'
 urlpatterns = router.urls
 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 rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 
 
 from extras import filters
 from extras import filters
+from extras.choices import JobResultStatusChoices
 from extras.models import (
 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 extras.scripts import get_script, get_scripts, run_script
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
+from utilities.utils import copy_safe_request
 from . import serializers
 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.
         Compile all reports and their related results (if any). Result data is deferred in the list view.
         """
         """
         report_list = []
         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.
         # Iterate through all available Reports.
         for module_name, reports in get_reports():
         for module_name, reports in get_reports():
             for report in 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)
                 report_list.append(report)
 
 
         serializer = serializers.ReportSerializer(report_list, many=True, context={
         serializer = serializers.ReportSerializer(report_list, many=True, context={
@@ -185,29 +195,43 @@ class ReportViewSet(ViewSet):
         Retrieve a single Report identified as "<module>.<report>".
         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 = 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)
         return Response(serializer.data)
 
 
     @action(detail=True, methods=['post'])
     @action(detail=True, methods=['post'])
     def run(self, request, pk):
     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.
         # 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.")
             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 = 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)
         return Response(serializer.data)
 
 
@@ -231,23 +255,42 @@ class ScriptViewSet(ViewSet):
 
 
     def list(self, request):
     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 = []
         flat_list = []
         for script_list in get_scripts().values():
         for script_list in get_scripts().values():
             flat_list.extend(script_list.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})
         serializer = serializers.ScriptSerializer(flat_list, many=True, context={'request': request})
 
 
         return Response(serializer.data)
         return Response(serializer.data)
 
 
     def retrieve(self, request, pk):
     def retrieve(self, request, pk):
         script = self._get_script(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)
         return Response(serializer.data)
 
 
     def post(self, request, pk):
     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)()
         script = self._get_script(pk)()
         input_serializer = serializers.ScriptInputSerializer(data=request.data)
         input_serializer = serializers.ScriptInputSerializer(data=request.data)
@@ -255,10 +298,21 @@ class ScriptViewSet(ViewSet):
         if input_serializer.is_valid():
         if input_serializer.is_valid():
             data = input_serializer.data['data']
             data = input_serializer.data['data']
             commit = input_serializer.data['commit']
             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)
         return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
@@ -274,3 +328,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     queryset = ObjectChange.objects.prefetch_related('user')
     queryset = ObjectChange.objects.prefetch_related('user')
     serializer_class = serializers.ObjectChangeSerializer
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filters.ObjectChangeFilterSet
     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
 # 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
 # Webhook content types
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
 
@@ -19,7 +5,8 @@ HTTP_CONTENT_TYPE_JSON = 'application/json'
 EXTRAS_FEATURES = [
 EXTRAS_FEATURES = [
     'custom_fields',
     'custom_fields',
     'custom_links',
     'custom_links',
-    'graphs',
     'export_templates',
     'export_templates',
+    'graphs',
+    'job_results',
     'webhooks'
     'webhooks'
 ]
 ]

+ 31 - 1
netbox/extras/filters.py

@@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.filters import BaseFilterSet
 from utilities.filters import BaseFilterSet
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
-from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
+from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
 
 
 
 
 __all__ = (
 __all__ = (
@@ -287,3 +287,33 @@ class CreatedUpdatedFilterSet(django_filters.FilterSet):
         field_name='last_updated',
         field_name='last_updated',
         lookup_expr='lte'
         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 .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
 from .models import (
 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
 from .tags import Tag, TaggedItem
 
 
@@ -16,8 +16,9 @@ __all__ = (
     'ExportTemplate',
     'ExportTemplate',
     'Graph',
     'Graph',
     'ImageAttachment',
     'ImageAttachment',
+    'JobResult',
     'ObjectChange',
     'ObjectChange',
-    'ReportResult',
+    'Report',
     'Script',
     'Script',
     'Tag',
     'Tag',
     'TaggedItem',
     'TaggedItem',

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

@@ -1,6 +1,8 @@
 import json
 import json
+import uuid
 from collections import OrderedDict
 from collections import OrderedDict
 
 
+import django_rq
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -10,6 +12,7 @@ from django.db import models
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.template import Template, Context
 from django.template import Template, Context
 from django.urls import reverse
 from django.urls import reverse
+from django.utils import timezone
 from rest_framework.utils.encoders import JSONEncoder
 from rest_framework.utils.encoders import JSONEncoder
 
 
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -17,7 +20,7 @@ from utilities.utils import deepmerge, render_jinja2
 from extras.choices import *
 from extras.choices import *
 from extras.constants import *
 from extras.constants import *
 from extras.querysets import ConfigContextQuerySet
 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
 # Custom scripts
 #
 #
 
 
+@extras_features('job_results')
 class Script(models.Model):
 class Script(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.
     """
     """
+
+    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:
     class Meta:
         managed = False
         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.
     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(
     created = models.DateTimeField(
         auto_now_add=True
         auto_now_add=True
     )
     )
+    completed = models.DateTimeField(
+        null=True,
+        blank=True
+    )
     user = models.ForeignKey(
     user = models.ForeignKey(
         to=User,
         to=User,
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -592,19 +622,69 @@ class ReportResult(models.Model):
         blank=True,
         blank=True,
         null=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:
     class Meta:
-        ordering = ['report']
+        ordering = ['obj_type', 'name', '-created']
 
 
     def __str__(self):
     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
 # Change logging

+ 60 - 20
netbox/extras/reports.py

@@ -5,10 +5,15 @@ import pkgutil
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
+from django.db.models import Q
 from django.utils import timezone
 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):
 def is_report(obj):
@@ -60,6 +65,32 @@ def get_reports():
     return module_list
     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):
 class Report(object):
     """
     """
     NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
     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__
         return self.__module__
 
 
     @property
     @property
-    def name(self):
+    def class_name(self):
         return self.__class__.__name__
         return self.__class__.__name__
 
 
+    @property
+    def name(self):
+        """
+        Override this attribute to set a custom display name.
+        """
+        return self.class_name
+
     @property
     @property
     def full_name(self):
     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.
         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))
             raise Exception("Unknown logging level: {}".format(level))
         self._results[self.active_test]['log'].append((
         self._results[self.active_test]['log'].append((
             timezone.now().isoformat(),
             timezone.now().isoformat(),
-            LOG_LEVEL_CODES.get(level),
+            level,
             str(obj) if obj else None,
             str(obj) if obj else None,
             obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
             obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
             message,
             message,
@@ -140,7 +178,7 @@ class Report(object):
         """
         """
         Log a message which is not associated with a particular 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)
         self.logger.info(message)
 
 
     def log_success(self, obj, message=None):
     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.
         Record a successful test against an object. Logging a message is optional.
         """
         """
         if message:
         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._results[self.active_test]['success'] += 1
         self.logger.info(f"Success | {obj}: {message}")
         self.logger.info(f"Success | {obj}: {message}")
 
 
@@ -156,7 +194,7 @@ class Report(object):
         """
         """
         Log an informational message.
         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._results[self.active_test]['info'] += 1
         self.logger.info(f"Info | {obj}: {message}")
         self.logger.info(f"Info | {obj}: {message}")
 
 
@@ -164,7 +202,7 @@ class Report(object):
         """
         """
         Log a warning.
         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._results[self.active_test]['warning'] += 1
         self.logger.info(f"Warning | {obj}: {message}")
         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.
         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._results[self.active_test]['failure'] += 1
         self.logger.info(f"Failure | {obj}: {message}")
         self.logger.info(f"Failure | {obj}: {message}")
         self.failed = True
         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")
         self.logger.info(f"Running report")
+        job_result.status = JobResultStatusChoices.STATUS_RUNNING
+        job_result.save()
 
 
         for method_name in self.test_methods:
         for method_name in self.test_methods:
             self.active_test = method_name
             self.active_test = method_name
             test_method = getattr(self, method_name)
             test_method = getattr(self, method_name)
             test_method()
             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:
         if self.failed:
             self.logger.warning("Report failed")
             self.logger.warning("Report failed")
+            job_result.status = JobResultStatusChoices.STATUS_FAILED
         else:
         else:
             self.logger.info("Report completed successfully")
             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
         # Perform any post-run tasks
         self.post_run()
         self.post_run()

+ 50 - 22
netbox/extras/scripts.py

@@ -12,12 +12,17 @@ from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.db import transaction
 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.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
 from mptt.models import MPTTModel
 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.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 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.exceptions import AbortTransaction
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from .forms import ScriptForm
 from .forms import ScriptForm
@@ -267,8 +272,20 @@ class BaseScript:
         self.source = inspect.getsource(self.__class__)
         self.source = inspect.getsource(self.__class__)
 
 
     def __str__(self):
     def __str__(self):
+        return self.name
+
+    @classproperty
+    def name(self):
         return getattr(self.Meta, 'name', self.__class__.__name__)
         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
     @classmethod
     def module(cls):
     def module(cls):
         return cls.__module__
         return cls.__module__
@@ -306,23 +323,23 @@ class BaseScript:
 
 
     def log_debug(self, message):
     def log_debug(self, message):
         self.logger.log(logging.DEBUG, 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):
     def log_success(self, message):
         self.logger.log(logging.INFO, message)  # No syslog equivalent for SUCCESS
         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):
     def log_info(self, message):
         self.logger.log(logging.INFO, 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):
     def log_warning(self, message):
         self.logger.log(logging.WARNING, 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):
     def log_failure(self, message):
         self.logger.log(logging.ERROR, message)
         self.logger.log(logging.ERROR, message)
-        self.log.append((LOG_FAILURE, message))
+        self.log.append((LogLevelChoices.LOG_FAILURE, message))
 
 
     # Convenience functions
     # Convenience functions
 
 
@@ -375,17 +392,21 @@ def is_variable(obj):
     return isinstance(obj, ScriptVariable)
     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
     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.
     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})")
     logger.info(f"Running script (commit={commit})")
 
 
     # Add files to form data
     # Add files to form data
@@ -405,13 +426,16 @@ def run_script(script, data, request, commit=True):
 
 
     try:
     try:
         with transaction.atomic():
         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:
             if not commit:
                 raise AbortTransaction()
                 raise AbortTransaction()
+
     except AbortTransaction:
     except AbortTransaction:
         pass
         pass
+
     except Exception as e:
     except Exception as e:
         stacktrace = traceback.format_exc()
         stacktrace = traceback.format_exc()
         script.log_failure(
         script.log_failure(
@@ -419,6 +443,8 @@ def run_script(script, data, request, commit=True):
         )
         )
         logger.error(f"Exception raised during script execution: {e}")
         logger.error(f"Exception raised during script execution: {e}")
         commit = False
         commit = False
+        job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
+
     finally:
     finally:
         if not commit:
         if not commit:
             # Delete all pending changelog entries
             # Delete all pending changelog entries
@@ -427,14 +453,16 @@ def run_script(script, data, request, commit=True):
                 "Database changes have been reverted automatically."
                 "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):
 def get_scripts(use_names=False):

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

@@ -1,6 +1,6 @@
 from django import template
 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()
 register = template.Library()
@@ -11,27 +11,7 @@ def log_level(level):
     """
     """
     Display a label indicating a syslog severity (e.g. info, warning, etc.).
     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 rest_framework import status
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
 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.models import ConfigContext, Graph, ExportTemplate, Tag
+from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
@@ -207,6 +208,38 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(rendered_context['bar'], 456)
         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 ScriptTest(APITestCase):
 
 
     class TestScript(Script):
     class TestScript(Script):
@@ -263,13 +296,7 @@ class ScriptTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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):
 class CreatedUpdatedFilterTest(APITestCase):

+ 4 - 3
netbox/extras/urls.py

@@ -36,11 +36,12 @@ urlpatterns = [
 
 
     # Reports
     # Reports
     path('reports/', views.ReportListView.as_view(), name='report_list'),
     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
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     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 import template
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
@@ -13,15 +15,16 @@ from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 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 (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-    ObjectPermissionRequiredMixin,
+    ContentTypePermissionRequiredMixin,
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from . import filters, forms, tables
 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
 from .scripts import get_scripts, run_script
 
 
 
 
@@ -314,9 +317,9 @@ class ImageAttachmentDeleteView(ObjectDeleteView):
 # Reports
 # 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):
     def get_required_permission(self):
         return 'extras.view_reportresult'
         return 'extras.view_reportresult'
@@ -324,7 +327,14 @@ class ReportListView(ObjectPermissionRequiredMixin, View):
     def get(self, request):
     def get(self, request):
 
 
         reports = get_reports()
         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 = []
         ret = []
         for module, report_list in reports:
         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):
     def get_required_permission(self):
         return 'extras.view_reportresult'
         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', {
         return render(request, 'extras/report.html', {
             'report': report,
             'report': report,
             'run_form': ConfirmationForm(),
             '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)
         form = ConfirmationForm(request.POST)
         if form.is_valid():
         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
 # 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):
     def get_required_permission(self):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request):
     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', {
         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):
     def get_required_permission(self):
         return 'extras.view_script'
         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):
     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)
         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', {
         return render(request, 'extras/script.html', {
             'module': module,
             'module': module,
             'script': script,
             'script': script,
@@ -434,19 +503,47 @@ class ScriptView(ObjectPermissionRequiredMixin, View):
         if not request.user.has_perm('extras.run_script'):
         if not request.user.has_perm('extras.run_script'):
             return HttpResponseForbidden()
             return HttpResponseForbidden()
 
 
-        script = self._get_script(module, name)
+        script = self._get_script(name, module)
         form = script.as_form(request.POST, request.FILES)
         form = script.as_form(request.POST, request.FILES)
-        output = None
-        execution_time = None
 
 
         if form.is_valid():
         if form.is_valid():
             commit = form.cleaned_data.pop('_commit')
             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', {
         return render(request, 'extras/script.html', {
             'module': module,
             'module': module,
             'script': script,
             'script': script,
             'form': form,
             '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 collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, F
 from django.db.models import Count, F
 from django.shortcuts import render
 from django.shortcuts import render
 from django.views.generic import View
 from django.views.generic import View
@@ -24,7 +25,8 @@ from dcim.tables import (
     CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
     CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
     VirtualChassisTable,
     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.filters import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
 from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
@@ -187,6 +189,13 @@ class HomeView(View):
             pk__lt=F('_connected_interface')
             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 = {
         stats = {
 
 
             # Organization
             # Organization
@@ -241,7 +250,7 @@ class HomeView(View):
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'search_form': SearchForm(),
             'search_form': SearchForm(),
             'stats': stats,
             'stats': stats,
-            'report_results': ReportResult.objects.order_by('-created')[:10],
+            'report_results': report_results,
             'changelog': changelog[:15],
             'changelog': changelog[:15],
             'new_release': new_release,
             '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>
             </ol>
         </div>
         </div>
     </div>
     </div>
-    {% if perms.extras.add_reportresult %}
+    {% if perms.extras.run_report %}
         <div class="pull-right noprint">
         <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 %}
                 {% csrf_token %}
                 {{ run_form }}
                 {{ run_form }}
                 <button type="submit" name="_run" class="btn btn-primary"><i class="fa fa-play"></i> Run Report</button>
                 <button type="submit" name="_run" class="btn btn-primary"><i class="fa fa-play"></i> Run Report</button>
             </form>
             </form>
         </div>
         </div>
     {% endif %}
     {% endif %}
-    <h1>{{ report.name }}{% include 'extras/inc/report_label.html' with result=report.result %}</h1>
+    <h1>{{ report.name }}</h1>
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
             {% if report.description %}
             {% if report.description %}
                 <p class="lead">{{ report.description }}</p>
                 <p class="lead">{{ report.description }}</p>
             {% endif %}
             {% endif %}
             {% if report.result %}
             {% 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 %}
             {% endif %}
         </div>
         </div>
     </div>
     </div>

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

@@ -6,7 +6,7 @@
     <div class="row">
     <div class="row">
         <div class="col-md-9">
         <div class="col-md-9">
             {% if reports %}
             {% if reports %}
-            {% for module, module_reports in reports %}
+                {% for module, module_reports in reports %}
                     <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
                     <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
                     <table class="table table-hover table-headings reports">
                     <table class="table table-hover table-headings reports">
                         <thead>
                         <thead>
@@ -21,17 +21,21 @@
                             {% for report in module_reports %}
                             {% for report in module_reports %}
                                 <tr>
                                 <tr>
                                     <td>
                                     <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>
                                     <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>
-                                    <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>
                                 </tr>
                                 {% for method, stats in report.result.data.items %}
                                 {% for method, stats in report.result.data.items %}
                                     <tr>
                                     <tr>
@@ -66,10 +70,10 @@
                         </div>
                         </div>
                         <ul class="list-group">
                         <ul class="list-group">
                             {% for report in module_reports %}
                             {% 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 }}
                                     <i class="fa fa-list-alt"></i> {{ report.name }}
                                     <div class="pull-right">
                                     <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>
                                     </div>
                                 </a>
                                 </a>
                             {% endfor %}
                             {% 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">
         <li role="presentation" class="active">
             <a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
             <a href="#run" role="tab" data-toggle="tab" class="active">Run</a>
         </li>
         </li>
-        <li role="presentation"{% if not output %} class="disabled"{% endif %}>
-            <a href="#output" role="tab" data-toggle="tab">Output</a>
-        </li>
         <li role="presentation">
         <li role="presentation">
             <a href="#source" role="tab" data-toggle="tab">Source</a>
             <a href="#source" role="tab" data-toggle="tab">Source</a>
         </li>
         </li>
     </ul>
     </ul>
     <div class="tab-content">
     <div class="tab-content">
         <div role="tabpanel" class="tab-pane active" id="run">
         <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="row">
                 <div class="col-md-6 col-md-offset-3">
                 <div class="col-md-6 col-md-offset-3">
                     {% if not perms.extras.run_script %}
                     {% if not perms.extras.run_script %}
@@ -100,9 +61,6 @@
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
-        <div role="tabpanel" class="tab-pane" id="output">
-            <pre>{{ output }}</pre>
-        </div>
         <div role="tabpanel" class="tab-pane" id="source">
         <div role="tabpanel" class="tab-pane" id="source">
             <p><code>{{ script.filename }}</code></p>
             <p><code>{{ script.filename }}</code></p>
             <pre>{{ script.source }}</pre>
             <pre>{{ script.source }}</pre>

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

@@ -4,15 +4,17 @@
 {% block content %}
 {% block content %}
     <h1>{% block title %}Scripts{% endblock %}</h1>
     <h1>{% block title %}Scripts{% endblock %}</h1>
     <div class="row">
     <div class="row">
-        <div class="col-md-12">
+        <div class="col-md-9">
             {% if scripts %}
             {% if scripts %}
                 {% for module, module_scripts in scripts.items %}
                 {% for module, module_scripts in scripts.items %}
                     <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
                     <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
                     <table class="table table-hover table-headings reports">
                     <table class="table table-hover table-headings reports">
                         <thead>
                         <thead>
                             <tr>
                             <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>
                             </tr>
                         </thead>
                         </thead>
                         <tbody>
                         <tbody>
@@ -21,7 +23,15 @@
                                     <td>
                                     <td>
                                         <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
                                         <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
                                     </td>
                                     </td>
+                                    <td>
+                                        {% include 'extras/inc/job_label.html' with result=script.result %}
+                                    </td>
                                     <td>{{ script.Meta.description }}</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>
                                 </tr>
                             {% endfor %}
                             {% endfor %}
                         </tbody>
                         </tbody>
@@ -34,5 +44,26 @@
                 </div>
                 </div>
             {% endif %}
             {% endif %}
         </div>
         </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>
     </div>
 {% endblock %}
 {% 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">
                 <table class="table table-hover panel-body">
                     {% for result in report_results %}
                     {% for result in report_results %}
                         <tr>
                         <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>
                         </tr>
                     {% endfor %}
                     {% endfor %}
                 </table>
                 </table>

+ 22 - 0
netbox/utilities/constants.py

@@ -42,3 +42,25 @@ ADVISORY_LOCK_KEYS = {
     'available-prefixes': 100100,
     'available-prefixes': 100100,
     'available-ips': 100200,
     '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.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
 from django.db.models import Count, OuterRef, Subquery
 from django.http import QueryDict
 from django.http import QueryDict
+from django.http.request import HttpRequest
 from jinja2 import Environment
 from jinja2 import Environment
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
 from extras.utils import is_taggable
 from extras.utils import is_taggable
+from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
 
 
 
 
 def csv_format(data):
 def csv_format(data):
@@ -257,3 +259,36 @@ def flatten_dict(d, prefix='', separator='.'):
         else:
         else:
             ret[key] = v
             ret[key] = v
     return ret
     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
 # 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):
 class ObjectPermissionRequiredMixin(AccessMixin):
     """
     """
     Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
     Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level