diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 808d7ce32..629edf5ce 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -2,7 +2,7 @@ from django import forms from django.contrib import admin from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook +from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook from .reports import get_report @@ -228,27 +228,18 @@ class ExportTemplateAdmin(admin.ModelAdmin): # Reports # -@admin.register(ReportResult) -class ReportResultAdmin(admin.ModelAdmin): +@admin.register(JobResult) +class JobResultAdmin(admin.ModelAdmin): list_display = [ - 'report', 'active', 'created', 'user', 'passing', + 'obj_type', 'name', 'created', 'completed', 'user', 'status', ] fields = [ - 'report', 'user', 'passing', 'data', + 'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id' ] list_filter = [ - 'failed', + 'status', ] readonly_fields = fields def has_add_permission(self, request): return False - - def active(self, obj): - module, report_name = obj.report.split('.') - return True if get_report(module, report_name) else False - active.boolean = True - - def passing(self, obj): - return not obj.failed - passing.boolean = True diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 4e95b389b..e3fb2e0db 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -1,13 +1,14 @@ from rest_framework import serializers -from extras import models -from utilities.api import WritableNestedSerializer +from extras import choices, models +from users.api.nested_serializers import NestedUserSerializer +from utilities.api import ChoiceField, WritableNestedSerializer __all__ = [ 'NestedConfigContextSerializer', 'NestedExportTemplateSerializer', 'NestedGraphSerializer', - 'NestedReportResultSerializer', + 'NestedJobResultSerializer', 'NestedTagSerializer', ] @@ -44,13 +45,13 @@ class NestedTagSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'slug', 'color'] -class NestedReportResultSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='extras-api:report-detail', - lookup_field='report', - lookup_url_kwarg='pk' +class NestedJobResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') + status = ChoiceField(choices=choices.JobResultStatusChoices) + user = NestedUserSerializer( + read_only=True ) class Meta: - model = models.ReportResult - fields = ['url', 'created', 'user', 'failed'] + model = models.JobResult + fields = ['url', 'created', 'completed', 'user', 'status'] diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5bf664b2f..2a8cae35c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -11,7 +11,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.choices import * from extras.constants import * from extras.models import ( - ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, ) from extras.utils import FeatureQuery from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer @@ -232,27 +232,46 @@ class ConfigContextSerializer(ValidatedModelSerializer): ] +# +# Job Results +# + +class JobResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') + user = NestedUserSerializer( + read_only=True + ) + status = ChoiceField(choices=JobResultStatusChoices, read_only=True) + obj_type = ContentTypeField( + read_only=True + ) + + class Meta: + model = JobResult + fields = [ + 'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', + ] + + # # Reports # -class ReportResultSerializer(serializers.ModelSerializer): - - class Meta: - model = ReportResult - fields = ['created', 'user', 'failed', 'data'] - - class ReportSerializer(serializers.Serializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='full_name', + lookup_url_kwarg='pk' + ) module = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255) description = serializers.CharField(max_length=255, required=False) test_methods = serializers.ListField(child=serializers.CharField(max_length=255)) - result = NestedReportResultSerializer() + result = NestedJobResultSerializer() class ReportDetailSerializer(ReportSerializer): - result = ReportResultSerializer() + result = JobResultSerializer() # @@ -260,10 +279,16 @@ class ReportDetailSerializer(ReportSerializer): # class ScriptSerializer(serializers.Serializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:script-detail', + lookup_field='full_name', + lookup_url_kwarg='pk' + ) id = serializers.SerializerMethodField(read_only=True) name = serializers.SerializerMethodField(read_only=True) description = serializers.SerializerMethodField(read_only=True) vars = serializers.SerializerMethodField(read_only=True) + result = NestedJobResultSerializer() def get_id(self, instance): return '{}.{}'.format(instance.__module__, instance.__name__) @@ -280,6 +305,10 @@ class ScriptSerializer(serializers.Serializer): } +class ScriptDetailSerializer(ScriptSerializer): + result = JobResultSerializer() + + class ScriptInputSerializer(serializers.Serializer): data = serializers.JSONField() commit = serializers.BooleanField() diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 8d8463bad..9927215df 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -41,5 +41,8 @@ router.register('scripts', views.ScriptViewSet, basename='script') # Change logging router.register('object-changes', views.ObjectChangeViewSet) +# Job Results +router.register('job-results', views.JobResultViewSet) + app_name = 'extras-api' urlpatterns = router.urls diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index b40cb4459..e2ee57059 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -10,8 +10,9 @@ from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from extras import filters +from extras.choices import JobResultStatusChoices from extras.models import ( - ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag, + ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, ) from extras.reports import get_report, get_reports from extras.scripts import get_script, get_scripts, run_script @@ -165,13 +166,21 @@ class ReportViewSet(ViewSet): Compile all reports and their related results (if any). Result data is deferred in the list view. """ report_list = [] + report_content_type = ContentType.objects.get(app_label='extras', model='report') + results = { + r.name: r + for r in JobResult.objects.filter( + obj_type=report_content_type, + status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES + ).defer('data') + } # Iterate through all available Reports. for module_name, reports in get_reports(): for report in reports: - # Attach the relevant ReportResult (if any) to each Report. - report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first() + # Attach the relevant JobResult (if any) to each Report. + report.result = results.get(report.full_name, None) report_list.append(report) serializer = serializers.ReportSerializer(report_list, many=True, context={ @@ -185,27 +194,41 @@ class ReportViewSet(ViewSet): Retrieve a single Report identified as ".". """ - # Retrieve the Report and ReportResult, if any. + # Retrieve the Report and JobResult, if any. report = self._retrieve_report(pk) - report.result = ReportResult.objects.filter(report=report.full_name).first() + 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) + serializer = serializers.ReportDetailSerializer(report, context={ + 'request': request + }) return Response(serializer.data) @action(detail=True, methods=['post']) def run(self, request, pk): """ - Run a Report and create a new ReportResult, overwriting any previous result for the Report. + Run a Report identified as ". + +{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 7de085974..4cf52243a 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -24,7 +24,7 @@ {{ report.name }} - {% include 'extras/inc/report_label.html' with result=report.result %} + {% include 'extras/inc/job_label.html' with result=report.result %} {{ report.description|default:"" }} {% if report.result %} @@ -69,7 +69,7 @@ {{ report.name }}
- {% include 'extras/inc/report_label.html' with result=report.result %} + {% include 'extras/inc/job_label.html' with result=report.result %}
{% endfor %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 01dc4bfa5..76a86b435 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -21,51 +21,12 @@ -
  • Source
  • - {% if execution_time or script.log %} -
    -
    -
    -
    - Script Log -
    - - - - - - - {% for level, message in script.log %} - - - - - - {% empty %} - - - - {% endfor %} -
    LineLevelMessage
    {{ forloop.counter }}{% log_level level %}{{ message|render_markdown }}
    - No log output -
    - {% if execution_time %} - - {% endif %} -
    -
    -
    - {% endif %}
    {% if not perms.extras.run_script %} @@ -100,9 +61,6 @@
    -
    -
    {{ output }}
    -

    {{ script.filename }}

    {{ script.source }}
    diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html new file mode 100644 index 000000000..64e242a56 --- /dev/null +++ b/netbox/templates/extras/script_result.html @@ -0,0 +1,107 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load form_helpers %} +{% load log_levels %} +{% load static %} + +{% block title %}{{ script }}{% endblock %} + +{% block content %} +
    +
    + +
    +
    +

    {{ script }}

    +

    {{ script.Meta.description }}

    + +
    +

    + Run: {{ result.created }} + {% if result.completed %} + Duration: {{ result.duration }} + {% else %} + {% include 'extras/inc/job_label.html' with result=result %} + + {% endif %} +

    +
    + {% if result.completed %} +
    +
    +
    +
    + Script Log +
    + + + + + + + {% for log in result.data.log %} + + + + + + {% empty %} + + + + {% endfor %} +
    LineLevelMessage
    {{ forloop.counter }}{% log_level log.status %}{{ log.message|render_markdown }}
    + No log output +
    + {% if execution_time %} + + {% endif %} +
    +
    +
    + {% endif %} +
    +
    +
    {{ result.data.output }}
    +
    +
    +

    {{ script.filename }}

    +
    {{ script.source }}
    +
    +
    +{% endblock %} + + +{% block javascript %} + + +{% endblock %} \ No newline at end of file diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 9a3a7d028..8cf047c42 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -42,3 +42,25 @@ ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, } + +# +# HTTP Request META safe copy +# + +HTTP_REQUEST_META_SAFE_COPY = [ + 'CONTENT_LENGTH', + 'CONTENT_TYPE', + 'HTTP_ACCEPT', + 'HTTP_ACCEPT_ENCODING', + 'HTTP_ACCEPT_LANGUAGE', + 'HTTP_HOST', + 'HTTP_REFERER', + 'HTTP_USER_AGENT', + 'QUERY_STRING', + 'REMOTE_ADDR', + 'REMOTE_HOST', + 'REMOTE_USER', + 'REQUEST_METHOD', + 'SERVER_NAME', + 'SERVER_PORT', +] diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 4c07f5520..f734e6534 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -5,11 +5,12 @@ from collections import OrderedDict from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.http import QueryDict +from django.http.request import HttpRequest from jinja2 import Environment from dcim.choices import CableLengthUnitChoices from extras.utils import is_taggable - +from utilities.constants import HTTP_REQUEST_META_SAFE_COPY def csv_format(data): """ @@ -257,3 +258,37 @@ def flatten_dict(d, prefix='', separator='.'): else: ret[key] = v return ret + + +# +# Fake request object +# + +class NetBoxFakeRequest: + """ + A fake request object which is explicitly defined at the module level so it is able to be pickled. It simply + takes what is passed to it as kwargs on init and sets them as instance variables. + """ + def __init__(self, *args, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +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 + }) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 22788cf64..3f921941e 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -39,6 +39,40 @@ from .paginator import EnhancedPaginator, get_paginate_count # Mixins # +class ContentTypePermissionRequiredMixin(AccessMixin): + """ + Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments. + This is related to ObjectPermissionRequiredMixin, except that is does not enforce object-level permissions, + and fits within NetBox's custom permission enforcement system. + + additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those + derived from the object type + """ + additional_permissions = list() + + def get_required_permission(self): + """ + Return the specific permission necessary to perform the requested action on an object. + """ + raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()") + + def has_permission(self): + user = self.request.user + permission_required = self.get_required_permission() + + # Check that the user has been granted the required permission(s). + if user.has_perms((permission_required, *self.additional_permissions)): + return True + + return False + + def dispatch(self, request, *args, **kwargs): + if not self.has_permission(): + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + class ObjectPermissionRequiredMixin(AccessMixin): """ Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level