mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 01:48:38 -06:00
Merge pull request #4799 from netbox-community/2006-scripts-reports-background
2006 scripts reports background
This commit is contained in:
commit
6d0281adc8
@ -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)
|
@admin.register(JobResult)
|
||||||
class ReportResultAdmin(admin.ModelAdmin):
|
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
|
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from extras import models
|
from extras import choices, models
|
||||||
from utilities.api import WritableNestedSerializer
|
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):
|
class NestedJobResultSerializer(serializers.ModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
|
||||||
view_name='extras-api:report-detail',
|
status = ChoiceField(choices=choices.JobResultStatusChoices)
|
||||||
lookup_field='report',
|
user = NestedUserSerializer(
|
||||||
lookup_url_kwarg='pk'
|
read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ReportResult
|
model = models.JobResult
|
||||||
fields = ['url', 'created', 'user', 'failed']
|
fields = ['url', 'created', 'completed', 'user', 'status']
|
||||||
|
@ -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
|
||||||
@ -232,27 +232,42 @@ 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
|
# Reports
|
||||||
#
|
#
|
||||||
|
|
||||||
class ReportResultSerializer(serializers.ModelSerializer):
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ReportResult
|
|
||||||
fields = ['created', 'user', 'failed', 'data']
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
id = serializers.CharField(read_only=True, source="full_name")
|
||||||
name = serializers.SerializerMethodField(read_only=True)
|
module = serializers.CharField(max_length=255)
|
||||||
description = serializers.SerializerMethodField(read_only=True)
|
name = serializers.CharField(read_only=True)
|
||||||
|
description = serializers.CharField(read_only=True)
|
||||||
vars = serializers.SerializerMethodField(read_only=True)
|
vars = serializers.SerializerMethodField(read_only=True)
|
||||||
|
result = NestedJobResultSerializer()
|
||||||
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', '')
|
|
||||||
|
|
||||||
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]
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
# Attach the relevant JobResult (if any) to each Report.
|
||||||
report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
|
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()
|
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)
|
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
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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'
|
||||||
]
|
]
|
||||||
|
@ -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
netbox/extras/migrations/0043_report.py
Normal file
22
netbox/extras/migrations/0043_report.py
Normal file
@ -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
netbox/extras/migrations/0044_jobresult.py
Normal file
75
netbox/extras/migrations/0044_jobresult.py
Normal file
@ -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'
|
||||||
|
)
|
||||||
|
]
|
@ -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,
|
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange,
|
||||||
Script, Webhook,
|
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',
|
||||||
|
@ -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:
|
class Meta:
|
||||||
managed = False
|
managed = False
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Report results
|
# Reports
|
||||||
#
|
#
|
||||||
|
|
||||||
class ReportResult(models.Model):
|
@extras_features('job_results')
|
||||||
|
class Report(models.Model):
|
||||||
|
"""
|
||||||
|
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Job results
|
||||||
|
#
|
||||||
|
|
||||||
|
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(
|
name = models.CharField(
|
||||||
max_length=255,
|
max_length=255
|
||||||
unique=True
|
)
|
||||||
|
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()
|
status = models.CharField(
|
||||||
data = JSONField()
|
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(
|
return str(self.job_id)
|
||||||
self.report,
|
|
||||||
"passed" if not self.failed else "failed",
|
@property
|
||||||
self.created
|
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
|
||||||
|
@ -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 .choices import JobResultStatusChoices, LogLevelChoices
|
||||||
from .models import ReportResult
|
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
|
@property
|
||||||
def full_name(self):
|
def name(self):
|
||||||
return '.'.join([self.__module__, self.__class__.__name__])
|
"""
|
||||||
|
Override this attribute to set a custom display name.
|
||||||
|
"""
|
||||||
|
return self.class_name
|
||||||
|
|
||||||
def _log(self, obj, message, level=LOG_DEFAULT):
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return f'{self.module}.{self.class_name}'
|
||||||
|
|
||||||
|
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()
|
||||||
|
@ -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
|
job_result = kwargs.pop('job_result')
|
||||||
start_time = None
|
module, script_name = job_result.name.split('.', 1)
|
||||||
end_time = None
|
|
||||||
|
|
||||||
script_name = script.__class__.__name__
|
script = get_script(module, script_name)()
|
||||||
logger = logging.getLogger(f"netbox.scripts.{script.module()}.{script_name}")
|
|
||||||
|
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
||||||
|
job_result.save()
|
||||||
|
|
||||||
|
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()
|
script.output = script.run(**kwargs)
|
||||||
output = script.run(**kwargs)
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
end_time = time.time()
|
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
|
logger.info(f"Script completed in {job_result.duration}")
|
||||||
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
|
|
||||||
|
|
||||||
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):
|
||||||
|
@ -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 = {
|
return {
|
||||||
LOG_DEFAULT: {
|
'name': LogLevelChoices.as_dict()[level],
|
||||||
'name': 'Default',
|
'class': dict(LogLevelChoices.CLASS_MAP)[level]
|
||||||
'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 levels[level]
|
|
||||||
|
@ -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['result']['status']['value'], 'pending')
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
class CreatedUpdatedFilterTest(APITestCase):
|
class CreatedUpdatedFilterTest(APITestCase):
|
||||||
|
@ -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:module>.<str:name>/', views.ReportView.as_view(), name='report'),
|
||||||
path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
|
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'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -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 .choices import JobResultStatusChoices
|
||||||
from .reports import get_report, get_reports
|
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>"
|
report = self._get_report(name, module)
|
||||||
module_name, report_name = name.split('.')
|
|
||||||
report = get_report(module_name, report_name)
|
|
||||||
if report is None:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
# Attach the ReportResult (if any)
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
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):
|
# Permissions check
|
||||||
"""
|
if not request.user.has_perm('extras.run_report'):
|
||||||
Run a Report and record a new ReportResult.
|
return HttpResponseForbidden()
|
||||||
"""
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.add_reportresult'
|
|
||||||
|
|
||||||
def post(self, request, name):
|
report = self._get_report(name, module)
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
form = ConfirmationForm(request.POST)
|
form = ConfirmationForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|
||||||
# Run the Report. A new ReportResult is created.
|
# Run the Report. A new JobResult is created.
|
||||||
report.run()
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
result = 'failed' if report.failed else 'passed'
|
job_result = JobResult.enqueue_job(
|
||||||
msg = "Ran report {} ({})".format(report.full_name, result)
|
run_report,
|
||||||
messages.success(request, mark_safe(msg))
|
report.full_name,
|
||||||
|
report_content_type,
|
||||||
|
request.user
|
||||||
|
)
|
||||||
|
|
||||||
return redirect('extras:report', name=report.full_name)
|
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
||||||
|
|
||||||
|
return render(request, 'extras/report.html', {
|
||||||
|
'report': report,
|
||||||
|
'run_form': form,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
def get_required_permission(self):
|
if module is None:
|
||||||
return 'extras.view_script'
|
module, name = name.split('.', 1)
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
|
|
||||||
return render(request, 'extras/script_list.html', {
|
|
||||||
'scripts': get_scripts(use_names=True),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptView(ObjectPermissionRequiredMixin, View):
|
|
||||||
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.view_script'
|
|
||||||
|
|
||||||
def _get_script(self, module, name):
|
|
||||||
scripts = get_scripts()
|
scripts = get_scripts()
|
||||||
try:
|
try:
|
||||||
return scripts[module][name]()
|
return scripts[module][name]()
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||||
|
|
||||||
|
def get_required_permission(self):
|
||||||
|
return 'extras.view_script'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
|
||||||
|
scripts = get_scripts(use_names=True)
|
||||||
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
|
results = {
|
||||||
|
r.name: r
|
||||||
|
for r in JobResult.objects.filter(
|
||||||
|
obj_type=script_content_type,
|
||||||
|
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
|
).defer('data')
|
||||||
|
}
|
||||||
|
|
||||||
|
for _scripts in scripts.values():
|
||||||
|
for script in _scripts.values():
|
||||||
|
script.result = results.get(script.full_name)
|
||||||
|
|
||||||
|
return render(request, 'extras/script_list.html', {
|
||||||
|
'scripts': scripts,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||||
|
|
||||||
|
def get_required_permission(self):
|
||||||
|
return 'extras.view_script'
|
||||||
|
|
||||||
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__
|
||||||
})
|
})
|
||||||
|
@ -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
netbox/project-static/js/job_result.js
Normal file
47
netbox/project-static/js/job_result.js
Normal file
@ -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
netbox/templates/extras/inc/job_label.html
Normal file
13
netbox/templates/extras/inc/job_label.html
Normal file
@ -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 %}
|
@ -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 %}
|
|
@ -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>
|
<p>Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
|
||||||
{% endif %}
|
<strong>{{ report.result.created }}</strong>
|
||||||
{% if report.result %}
|
</a></p>
|
||||||
<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 %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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
netbox/templates/extras/report_result.html
Normal file
124
netbox/templates/extras/report_result.html
Normal file
@ -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 %}
|
@ -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>
|
||||||
|
@ -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>Name</th>
|
||||||
<th class="col-md-9">Description</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
netbox/templates/extras/script_result.html
Normal file
119
netbox/templates/extras/script_result.html
Normal file
@ -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 %}
|
@ -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><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/report_label.html' %}</span></td>
|
<td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/job_label.html' %}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
@ -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',
|
||||||
|
]
|
||||||
|
@ -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
|
||||||
|
})
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user