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

2006 scripts reports background
This commit is contained in:
Jeremy Stretch 2020-07-06 13:45:12 -04:00 committed by GitHub
commit 6d0281adc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1204 additions and 373 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
#

View File

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

View File

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

View 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,
},
)
]

View 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'
)
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}
}
});
})();
}
})

View 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 %}

View File

@ -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 %}

View File

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

View File

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

View 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 %}

View File

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

View File

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

View 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 %}

View File

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

View File

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

View File

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

View File

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